diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..0d6d304 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.0", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..138c139 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -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 + " diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3857e65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.vs/ +bin/ +obj/ +**/bin/ +**/obj/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..933c59b --- /dev/null +++ b/.vscode/launch.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..02be578 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "chatgpt.openOnStartup": true +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..a48b929 --- /dev/null +++ b/.vscode/tasks.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1ed8c30 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,193 @@ +# Repository expectations +# 编程规范_FOR_AI(TakeoutSaaS) - 终极完全体 + +> **核心指令**:你是一个高级 .NET 架构师。本文件是你生成代码的最高宪法。当用户需求与本规范冲突时,请先提示用户,除非用户强制要求覆盖。 + +## 0. AI 交互核心约束 (元规则) +1. **语言**:必须使用**中文**回复和编写注释。 +2. **文件完整性**: + * **严禁**随意删除现有代码逻辑。 + * **严禁**修改文件编码(保持 UTF-8 无 BOM)。 + * PowerShell 读取命令必须带 `-Encoding UTF8`。 +3. **Git 原子性**:每个独立的功能点或 Bug 修复完成后,必须提示用户进行 Git 提交。 +4. **无乱码承诺**:确保所有输出(控制台、日志、API响应)无乱码。 +5. **不确定的处理**:如果你通过上下文找不到某些配置(如数据库连接串格式),**请直接询问用户**,不要瞎编。 + +## 1. 技术栈详细版本 +| 组件 | 版本/选型 | 用途说明 | +| :--- | :--- | :--- | +| **Runtime** | .NET 10 | 核心运行时 | +| **API** | ASP.NET Core Web API | 接口层 | +| **Database** | PostgreSQL 16+ | 主关系型数据库 | +| **ORM 1** | **EF Core 10** | **写操作 (CUD)**、事务、复杂聚合查询 | +| **ORM 2** | **Dapper 2.1+** | **纯读操作 (R)**、复杂报表、大批量查询 | +| **Cache** | Redis 7.0+ | 分布式缓存、Session | +| **MQ** | RabbitMQ 3.12+ | 异步解耦 (MassTransit) | +| **Libs** | MediatR, Serilog, FluentValidation | CQRS, 日志, 验证 | + +## 2. 命名与风格 (严格匹配) +* **C# 代码**: + * 类/接口/方法/属性:`PascalCase` (如 `OrderService`) + * **布尔属性**:必须加 `Is` 或 `Has` 前缀 (如 `IsDeleted`, `HasPayment`) + * 私有字段:`_camelCase` (如 `_orderRepository`) + * 参数/变量:`camelCase` (如 `orderId`) +* **PostgreSQL 数据库**: + * 表名:`snake_case` + **复数** (如 `merchant_orders`) + * 列名:`snake_case` (如 `order_no`, `is_active`) + * 主键:`id` (类型 `bigint`) +* **文件规则**: + * **一个文件一个类**。文件名必须与类名完全一致。 + +## 3. 分层架构 (Clean Architecture) +**你生成的代码必须严格归类到以下目录:** +* **`src/Api`**: 仅负责路由与 DTO 转换,**禁止**包含业务逻辑。 +* **`src/Application`**: 业务编排层。必须使用 **CQRS** (`IRequestHandler`) 和 **Mediator**。 +* **`src/Domain`**: 核心领域层。包含实体、枚举、领域异常。**禁止**依赖 EF Core 等外部库。 +* **`src/Infrastructure`**: 基础设施层。实现仓储、数据库上下文、第三方服务。 + +## 4. 注释与文档 +* **强制 XML 注释**:所有 `public` 的类、方法、属性必须有 ``。 +* **步骤注释**:超过 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 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` 处理字符串/切片,避免 Substring/复制。 +2. **栈分配与数组池**:小缓冲用 `stackalloc`,大缓冲统一用 `ArrayPool.Shared`,禁止直接 `new` 大数组。 +3. **UTF-8 字面量**:常量字节使用 `"text"u8`,避免运行时编码。 +4. **避免装箱**:热点路径规避隐式装箱,必要时用 `ref struct` 约束栈分配。 +5. **Frozen 集合**:只读查找表用 `FrozenDictionary/FrozenSet`,初始化后不再修改。 +6. **SearchValues SIMD 查找**:Span 内多字符搜索用 `SearchValues.Create(...)` + `ContainsAny`。 +7. **预设集合容量**:`List/Dictionary` 预知规模必须指定 `Capacity`。 +8. **ValueTask 热点返回**:可能同步完成的异步返回 `ValueTask`,减少 Task 分配。 +9. **Parallel.ForEachAsync 控并发**:I/O 并发用 Parallel.ForEachAsync 控制并行度,替代粗暴 Task.WhenAll。 +10. **避免 Task.Run**:在 ASP.NET Core 请求中不使用 Task.Run 做后台工作,改用 IHostedService 或 Channel 模式。 +11. **Channel 代替锁**:多线程数据传递优先使用 Channels,实现无锁生产者-消费者。 +12. **NativeAOT/PGO/向量化**:微服务/工具开启 NativeAOT;保留动态 PGO;计算密集场景考虑 System.Runtime.Intrinsics。 +13. **System.Text.Json + 源生成器**:全面替换 Newtonsoft.Json;使用 `[JsonSerializable]` + 生成的 `JsonSerializerContext`,兼容 NativeAOT,零反射。 +14. **Pipelines 处理流**:TCP/文件流解析使用 `PipeReader/PipeWriter`,获得零拷贝与缓冲管理。 +15. **HybridCache**:内存+分布式缓存统一用 HybridCache,利用防击穿合并并发请求。 + +## 19. 架构优化(增量) +> 架构优化方案 +1. **Chiseled 容器优先**:生产镜像基于 `mcr.microsoft.com/dotnet/runtime-deps:10.0-jammy-chiseled`,无 Shell、非 root,缩小攻击面,符合零信任要求。 +2. **默认集成 OpenTelemetry**:架构内置 OTel,统一通过 OTLP 导出 Metrics/Traces/Logs,避免依赖专有 APM 探针。 +3. **内部同步调用首选 gRPC**:微服务间禁止 JSON over HTTP,同步调用统一使用 gRPC,配合 Protobuf 源生成器获取强类型契约与更小载荷。 +4. **Outbox 模式强制**:处理领域事件时,事件记录必须与业务数据同事务写入 Outbox 表;后台 Worker 轮询 Outbox 再推送 MQ(RabbitMQ/Kafka),禁止事务提交后直接发消息以避免不一致。 +5. **共享资源必加分布式锁**:涉及库存扣减、定时任务抢占等共享资源时,必须引入分布式锁(如 Redis RedLock),防止并发竞争与脏写。 + +--- + + +# Working agreements +- 严格遵循上述技术栈和命名规范。 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..59c4f40 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + latest + false + + + diff --git a/Document/01_项目概述.md b/Document/01_项目概述.md new file mode 100644 index 0000000..3d8920c --- /dev/null +++ b/Document/01_项目概述.md @@ -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个月) +- 测试与修复 +- 文档完善 +- 部署上线 +- 运维监控 + diff --git a/Document/02_技术架构.md b/Document/02_技术架构.md new file mode 100644 index 0000000..e7d1861 --- /dev/null +++ b/Document/02_技术架构.md @@ -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:服务接口 + - Validators:FluentValidation验证器 + - Mappings:AutoMapper配置 + - Commands/Queries:CQRS命令和查询 + +#### 2.2.3 领域层 (Domain Layer) +- **TakeoutSaaS.Domain**:领域模型 + - Entities:实体类 + - ValueObjects:值对象 + - Enums:枚举 + - Events:领域事件 + - Interfaces:仓储接口 + - Specifications:规约模式 + +#### 2.2.4 基础设施层 (Infrastructure Layer) +- **TakeoutSaaS.Infrastructure**:基础设施实现 + - Data:数据访问 + - EFCore:EF Core DbContext和配置 + - Dapper:Dapper查询实现 + - Repositories:仓储实现 + - Migrations:数据库迁移 + - Cache:Redis缓存实现 + - MessageQueue:RabbitMQ实现 + - 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 GetOrderWithDetailsAsync(Guid orderId) +{ + return await _dbContext.Orders + .Include(o => o.OrderItems) + .Include(o => o.Customer) + .FirstOrDefaultAsync(o => o.Id == orderId); +} + +// Dapper - 高性能统计查询 +public async Task 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(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跨域配置 + diff --git a/Document/03_数据库设计.md b/Document/03_数据库设计.md new file mode 100644 index 0000000..93074df --- /dev/null +++ b/Document/03_数据库设计.md @@ -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文件纳入版本控制 +- 生产环境变更需要审核 + diff --git a/Document/04_API接口设计.md b/Document/04_API接口设计.md new file mode 100644 index 0000000..1f3735d --- /dev/null +++ b/Document/04_API接口设计.md @@ -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": "满50减10", + "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: +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`:支付成功通知 + diff --git a/Document/05_部署运维.md b/Document/05_部署运维.md new file mode 100644 index 0000000..2d90a48 --- /dev/null +++ b/Document/05_部署运维.md @@ -0,0 +1,1011 @@ +# 外卖SaaS系统 - 部署运维 + +## 1. 环境要求 + +### 1.1 开发环境 +- **.NET SDK**:10.0 或更高版本 +- **IDE**:Visual Studio 2022 / JetBrains Rider / VS Code +- **数据库**:PostgreSQL 16+ +- **缓存**:Redis 7.0+ +- **消息队列**:RabbitMQ 3.12+ +- **Git**:版本控制 +- **Docker Desktop**:容器化开发(可选) + +### 1.2 生产环境 +- **操作系统**:Linux (Ubuntu 22.04 LTS / CentOS 8+) +- **运行时**:.NET Runtime 10.0 +- **Web服务器**:Nginx 1.24+ +- **数据库**:PostgreSQL 16+ (主从复制) +- **缓存**:Redis 7.0+ (哨兵模式) +- **消息队列**:RabbitMQ 3.12+ (集群模式) +- **对象存储**:MinIO / 阿里云OSS / 腾讯云COS +- **监控**:Prometheus + Grafana +- **日志**:ELK Stack (Elasticsearch + Logstash + Kibana) + +### 1.3 硬件要求(生产环境) +- **应用服务器**:4核8GB内存(最低),推荐8核16GB +- **数据库服务器**:8核16GB内存,SSD存储 +- **Redis服务器**:4核8GB内存 +- **负载均衡器**:2核4GB内存 + +## 2. 本地开发环境搭建 + +### 2.1 安装.NET SDK +```bash +# Windows +# 从官网下载安装:https://dotnet.microsoft.com/download + +# Linux (Ubuntu) +wget https://packages.microsoft.com/config/ubuntu/22.04/packages-microsoft-prod.deb +sudo dpkg -i packages-microsoft-prod.deb +sudo apt-get update +sudo apt-get install -y dotnet-sdk-10.0 + +# 验证安装 +dotnet --version +``` + +### 2.2 安装PostgreSQL +```bash +# Ubuntu +sudo apt-get update +sudo apt-get install -y postgresql-16 postgresql-contrib-16 + +# 启动服务 +sudo systemctl start postgresql +sudo systemctl enable postgresql + +# 创建数据库 +sudo -u postgres psql +CREATE DATABASE takeout_saas; +CREATE USER takeout_user WITH PASSWORD 'your_password'; +GRANT ALL PRIVILEGES ON DATABASE takeout_saas TO takeout_user; +\q +``` + +### 2.3 安装Redis +```bash +# Ubuntu +sudo apt-get install -y redis-server + +# 启动服务 +sudo systemctl start redis-server +sudo systemctl enable redis-server + +# 测试连接 +redis-cli ping +``` + +### 2.4 安装RabbitMQ +```bash +# Ubuntu +sudo apt-get install -y rabbitmq-server + +# 启动服务 +sudo systemctl start rabbitmq-server +sudo systemctl enable rabbitmq-server + +# 启用管理插件 +sudo rabbitmq-plugins enable rabbitmq_management + +# 创建用户 +sudo rabbitmqctl add_user admin password +sudo rabbitmqctl set_user_tags admin administrator +sudo rabbitmqctl set_permissions -p / admin ".*" ".*" ".*" + +# 访问管理界面:http://localhost:15672 +``` + +### 2.5 使用Docker Compose(推荐) +```yaml +# docker-compose.yml +version: '3.8' + +services: + postgres: + image: postgres:16 + container_name: takeout_postgres + environment: + POSTGRES_DB: takeout_saas + POSTGRES_USER: takeout_user + POSTGRES_PASSWORD: your_password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + container_name: takeout_redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + + rabbitmq: + image: rabbitmq:3.12-management + container_name: takeout_rabbitmq + environment: + RABBITMQ_DEFAULT_USER: admin + RABBITMQ_DEFAULT_PASS: password + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + + minio: + image: minio/minio:latest + container_name: takeout_minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: admin + MINIO_ROOT_PASSWORD: password123 + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + +volumes: + postgres_data: + redis_data: + rabbitmq_data: + minio_data: +``` + +```bash +# 启动所有服务 +docker-compose up -d + +# 查看服务状态 +docker-compose ps + +# 停止服务 +docker-compose down +``` + +### 2.6 配置项目 +```bash +# 克隆项目 +git clone https://github.com/your-org/takeout-saas.git +cd takeout-saas + +# 还原依赖 +dotnet restore + +# 配置appsettings.Development.json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=takeout_saas;Username=takeout_user;Password=your_password" + }, + "Redis": { + "Configuration": "localhost:6379" + }, + "RabbitMQ": { + "Host": "localhost", + "Port": 5672, + "Username": "admin", + "Password": "password" + } +} + +# 执行数据库迁移 +cd src/TakeoutSaaS.Api +dotnet ef database update + +# 运行项目 +dotnet run +``` + +### 2.7 EF Core 迁移基线 + +现已内置 `dotnet-ef` 本地工具与设计时 DbContext 工厂,可直接在命令行生成/更新数据库。运行前可通过环境变量 `TAKEOUTSAAS_APP_CONNECTION`、`TAKEOUTSAAS_IDENTITY_CONNECTION` 覆盖默认连接串(默认指向本地 PostgreSQL)。 + +```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,归属 AppDatabase) +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 +``` + +> Hangfire 使用 Scheduler.ConnectionString 指向的数据库,首次启动服务会自动建表;只需提前创建空数据库并授予账号权限。 + +## 3. Docker部署 + +### 3.1 创建Dockerfile +```dockerfile +# Dockerfile +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY ["src/TakeoutSaaS.Api/TakeoutSaaS.Api.csproj", "src/TakeoutSaaS.Api/"] +COPY ["src/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj", "src/TakeoutSaaS.Application/"] +COPY ["src/TakeoutSaaS.Domain/TakeoutSaaS.Domain.csproj", "src/TakeoutSaaS.Domain/"] +COPY ["src/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj", "src/TakeoutSaaS.Infrastructure/"] +COPY ["src/TakeoutSaaS.Shared/TakeoutSaaS.Shared.csproj", "src/TakeoutSaaS.Shared/"] +RUN dotnet restore "src/TakeoutSaaS.Api/TakeoutSaaS.Api.csproj" +COPY . . +WORKDIR "/src/src/TakeoutSaaS.Api" +RUN dotnet build "TakeoutSaaS.Api.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "TakeoutSaaS.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "TakeoutSaaS.Api.dll"] +``` + +### 3.2 构建镜像 +```bash +# 构建镜像 +docker build -t takeout-saas-api:latest . + +# 查看镜像 +docker images | grep takeout-saas + +# 运行容器 +docker run -d \ + --name takeout-api \ + -p 8080:80 \ + -e ASPNETCORE_ENVIRONMENT=Production \ + -e ConnectionStrings__DefaultConnection="Host=postgres;Port=5432;Database=takeout_saas;Username=takeout_user;Password=your_password" \ + takeout-saas-api:latest +``` + +### 3.3 生产环境Docker Compose +```yaml +# docker-compose.prod.yml +version: '3.8' + +services: + api: + image: takeout-saas-api:latest + container_name: takeout_api + restart: always + environment: + ASPNETCORE_ENVIRONMENT: Production + ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=takeout_saas;Username=takeout_user;Password=${DB_PASSWORD}" + Redis__Configuration: "redis:6379" + RabbitMQ__Host: "rabbitmq" + ports: + - "8080:80" + depends_on: + - postgres + - redis + - rabbitmq + networks: + - takeout_network + + nginx: + image: nginx:latest + container_name: takeout_nginx + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/ssl:/etc/nginx/ssl + depends_on: + - api + networks: + - takeout_network + +networks: + takeout_network: + driver: bridge +``` + +## 4. Nginx配置 + +### 4.1 基础配置 +```nginx +# /etc/nginx/nginx.conf +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # Gzip压缩 + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript + application/json application/javascript application/xml+rss + application/rss+xml font/truetype font/opentype + application/vnd.ms-fontobject image/svg+xml; + + # 限流配置 + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/s; + limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + + include /etc/nginx/conf.d/*.conf; +} +``` + +### 4.2 API服务配置 +```nginx +# /etc/nginx/conf.d/api.conf +upstream api_backend { + least_conn; + server api1:80 weight=1 max_fails=3 fail_timeout=30s; + server api2:80 weight=1 max_fails=3 fail_timeout=30s; + keepalive 32; +} + +server { + listen 80; + server_name api.example.com; + + # 重定向到HTTPS + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name api.example.com; + + # SSL证书配置 + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # 客户端请求体大小限制 + client_max_body_size 10M; + + # API接口 + location /api/ { + # 限流 + limit_req zone=api_limit burst=20 nodelay; + limit_conn conn_limit 10; + + proxy_pass http://api_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 超时设置 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # 缓冲设置 + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + proxy_busy_buffers_size 8k; + } + + # WebSocket + location /ws { + proxy_pass http://api_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # WebSocket超时 + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # 健康检查 + location /health { + proxy_pass http://api_backend; + access_log off; + } + + # 静态文件缓存 + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ { + proxy_pass http://api_backend; + expires 30d; + add_header Cache-Control "public, immutable"; + } +} +``` + +## 5. 数据库部署 + +### 5.1 PostgreSQL主从复制 +```bash +# 主库配置 (postgresql.conf) +listen_addresses = '*' +wal_level = replica +max_wal_senders = 10 +wal_keep_size = 64MB +hot_standby = on + +# 主库配置 (pg_hba.conf) +host replication replicator 192.168.1.0/24 md5 + +# 创建复制用户 +CREATE USER replicator WITH REPLICATION ENCRYPTED PASSWORD 'repl_password'; + +# 从库配置 +# 1. 停止从库 +sudo systemctl stop postgresql + +# 2. 清空从库数据目录 +rm -rf /var/lib/postgresql/16/main/* + +# 3. 从主库复制数据 +pg_basebackup -h master_ip -D /var/lib/postgresql/16/main -U replicator -P -v -R -X stream -C -S replica1 + +# 4. 启动从库 +sudo systemctl start postgresql + +# 5. 验证复制状态 +# 主库执行 +SELECT * FROM pg_stat_replication; +``` + +### 5.2 数据库备份脚本 +```bash +#!/bin/bash +# backup_db.sh + +BACKUP_DIR="/backup/postgres" +DATE=$(date +%Y%m%d_%H%M%S) +DB_NAME="takeout_saas" +DB_USER="takeout_user" +RETENTION_DAYS=30 + +# 创建备份目录 +mkdir -p $BACKUP_DIR + +# 全量备份 +pg_dump -h localhost -U $DB_USER -d $DB_NAME -F c -f $BACKUP_DIR/full_$DATE.dump + +# 压缩备份 +gzip $BACKUP_DIR/full_$DATE.dump + +# 删除过期备份 +find $BACKUP_DIR -name "full_*.dump.gz" -mtime +$RETENTION_DAYS -delete + +# 上传到对象存储(可选) +# aws s3 cp $BACKUP_DIR/full_$DATE.dump.gz s3://your-bucket/backups/ + +echo "Backup completed: full_$DATE.dump.gz" +``` + +### 5.3 定时备份(Crontab) +```bash +# 编辑crontab +crontab -e + +# 每天凌晨2点执行备份 +0 2 * * * /path/to/backup_db.sh >> /var/log/backup.log 2>&1 +``` + +## TODO:基础设施部署脚本 + +- [ ] PostgreSQL 主从:整理主库/从库初始化脚本、basebackup 步骤与故障切换手册。 +- [ ] Redis 哨兵/集群:补充 redis.conf/sentinel.conf 模板以及一主两从搭建命令。 +- [ ] RabbitMQ:编写单节点到镜像队列的安装脚本,记录 VHost、用户、权限、监控等操作。 +- [ ] 腾讯云 COS:整理桶创建、ACL、CDN 绑定与密钥轮换流程,并提供 coscmd/SDK 示例。 +- [ ] Hangfire 存储:确认 PostgreSQL Schema 初始化脚本,补充定期备份、清理、监控的 SOP。 + +## 6. Redis部署 + +### 6.1 Redis哨兵模式 +```bash +# redis.conf (主节点) +bind 0.0.0.0 +port 6379 +requirepass your_password +masterauth your_password + +# sentinel.conf +port 26379 +sentinel monitor mymaster 192.168.1.100 6379 2 +sentinel auth-pass mymaster your_password +sentinel down-after-milliseconds mymaster 5000 +sentinel parallel-syncs mymaster 1 +sentinel failover-timeout mymaster 10000 +``` + +### 6.2 Redis持久化配置 +```bash +# redis.conf +# RDB持久化 +save 900 1 +save 300 10 +save 60 10000 +rdbcompression yes +rdbchecksum yes +dbfilename dump.rdb + +# AOF持久化 +appendonly yes +appendfilename "appendonly.aof" +appendfsync everysec +no-appendfsync-on-rewrite no +auto-aof-rewrite-percentage 100 +auto-aof-rewrite-min-size 64mb +``` + +## 7. CI/CD配置 + +### 7.1 GitHub Actions +```yaml +# .github/workflows/deploy.yml +name: Deploy to Production + +on: + push: + branches: [ main ] + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '10.0.x' + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --no-build --verbosity normal + + - name: Publish + run: dotnet publish src/TakeoutSaaS.Api/TakeoutSaaS.Api.csproj -c Release -o ./publish + + - name: Build Docker image + run: | + docker build -t takeout-saas-api:${{ github.sha }} . + docker tag takeout-saas-api:${{ github.sha }} takeout-saas-api:latest + + - name: Push to Registry + run: | + echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin + docker push takeout-saas-api:${{ github.sha }} + docker push takeout-saas-api:latest + + - name: Deploy to Server + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cd /opt/takeout-saas + docker-compose pull + docker-compose up -d + docker system prune -f +``` + +### 7.2 GitLab CI +```yaml +# .gitlab-ci.yml +stages: + - build + - test + - deploy + +variables: + DOCKER_IMAGE: registry.example.com/takeout-saas-api + +build: + stage: build + image: mcr.microsoft.com/dotnet/sdk:10.0 + script: + - dotnet restore + - dotnet build --configuration Release + artifacts: + paths: + - src/*/bin/Release/ + +test: + stage: test + image: mcr.microsoft.com/dotnet/sdk:10.0 + script: + - dotnet test --configuration Release + +deploy: + stage: deploy + image: docker:latest + services: + - docker:dind + script: + - docker build -t $DOCKER_IMAGE:$CI_COMMIT_SHA . + - docker tag $DOCKER_IMAGE:$CI_COMMIT_SHA $DOCKER_IMAGE:latest + - docker push $DOCKER_IMAGE:$CI_COMMIT_SHA + - docker push $DOCKER_IMAGE:latest + only: + - main +``` + +## 8. 监控告警 + +### 8.1 Prometheus配置 +```yaml +# prometheus.yml +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'takeout-api' + static_configs: + - targets: ['api:80'] + metrics_path: '/metrics' + + - job_name: 'postgres' + static_configs: + - targets: ['postgres-exporter:9187'] + + - job_name: 'redis' + static_configs: + - targets: ['redis-exporter:9121'] + + - job_name: 'node' + static_configs: + - targets: ['node-exporter:9100'] +``` + +### 8.2 应用监控指标(OpenTelemetry + Prometheus Exporter) +```csharp +// Program.cs - 指标与探针 +builder.Services.AddHealthChecks(); +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddPrometheusExporter(); // /metrics + }); + +var app = builder.Build(); +app.MapHealthChecks("/healthz"); // 存活/就绪探针 +app.MapPrometheusScrapingEndpoint(); // 默认 /metrics +``` + +自定义业务指标(使用 `System.Diagnostics.Metrics`,由 Prometheus Exporter 暴露): +```csharp +internal static class BusinessMetrics +{ + private static readonly Meter Meter = new("TakeoutSaaS.App", "1.0.0"); + public static readonly Counter OrdersCreated = Meter.CreateCounter("orders_created_total", "个", "订单创建计数"); + public static readonly Histogram OrderProcessingSeconds = Meter.CreateHistogram("order_processing_duration_seconds", "s", "订单处理耗时"); +} +``` + +Prometheus 抓取示例:见 `deploy/prometheus/prometheus.yml`,默认拉取 `/metrics`,告警规则见 `deploy/prometheus/alert.rules.yml`。 + +### 8.3 Grafana仪表板 +```json +{ + "dashboard": { + "title": "外卖SaaS系统监控", + "panels": [ + { + "title": "API请求速率", + "targets": [ + { + "expr": "rate(http_requests_total[5m])" + } + ] + }, + { + "title": "订单创建数", + "targets": [ + { + "expr": "increase(orders_created_total[1h])" + } + ] + }, + { + "title": "数据库连接数", + "targets": [ + { + "expr": "pg_stat_activity_count" + } + ] + } + ] + } +} +``` + +### 8.4 告警规则 +```yaml +# alert.rules.yml +groups: + - name: takeout_alerts + interval: 30s + rules: + - alert: HighErrorRate + expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.05 + for: 5m + labels: + severity: critical + annotations: + summary: "高错误率告警" + description: "API错误率超过5%" + + - alert: DatabaseDown + expr: up{job="postgres"} == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "数据库宕机" + description: "PostgreSQL数据库不可用" + + - alert: HighMemoryUsage + expr: (node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / node_memory_MemTotal_bytes > 0.9 + for: 5m + labels: + severity: warning + annotations: + summary: "内存使用率过高" + description: "内存使用率超过90%" +``` + +## 9. 日志管理 + +### 9.1 Serilog配置 +```json +{ + "Serilog": { + "Using": ["Serilog.Sinks.Console", "Serilog.Sinks.File", "Serilog.Sinks.Elasticsearch"], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" + } + }, + { + "Name": "File", + "Args": { + "path": "logs/log-.txt", + "rollingInterval": "Day", + "retainedFileCountLimit": 30 + } + }, + { + "Name": "Elasticsearch", + "Args": { + "nodeUris": "http://elasticsearch:9200", + "indexFormat": "takeout-logs-{0:yyyy.MM.dd}", + "autoRegisterTemplate": true + } + } + ], + "Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"] + } +} +``` + +### 9.2 ELK Stack部署 +```yaml +# docker-compose.elk.yml +version: '3.8' + +services: + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0 + environment: + - discovery.type=single-node + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + ports: + - "9200:9200" + volumes: + - es_data:/usr/share/elasticsearch/data + + logstash: + image: docker.elastic.co/logstash/logstash:8.11.0 + volumes: + - ./logstash/pipeline:/usr/share/logstash/pipeline + ports: + - "5044:5044" + depends_on: + - elasticsearch + + kibana: + image: docker.elastic.co/kibana/kibana:8.11.0 + ports: + - "5601:5601" + environment: + ELASTICSEARCH_HOSTS: http://elasticsearch:9200 + depends_on: + - elasticsearch + +volumes: + es_data: +``` + +## 10. 安全加固 + +### 10.1 防火墙配置 +```bash +# UFW防火墙 +sudo ufw enable +sudo ufw allow 22/tcp # SSH +sudo ufw allow 80/tcp # HTTP +sudo ufw allow 443/tcp # HTTPS +sudo ufw deny 5432/tcp # 禁止外部访问数据库 +sudo ufw deny 6379/tcp # 禁止外部访问Redis +``` + +### 10.2 SSL证书(Let's Encrypt) +```bash +# 安装Certbot +sudo apt-get install certbot python3-certbot-nginx + +# 获取证书 +sudo certbot --nginx -d api.example.com + +# 自动续期 +sudo certbot renew --dry-run + +# 添加定时任务 +0 3 * * * certbot renew --quiet +``` + +### 10.3 应用安全配置 +```csharp +// Program.cs +builder.Services.AddHsts(options => +{ + options.MaxAge = TimeSpan.FromDays(365); + options.IncludeSubDomains = true; + options.Preload = true; +}); + +builder.Services.AddHttpsRedirection(options => +{ + options.RedirectStatusCode = StatusCodes.Status308PermanentRedirect; + options.HttpsPort = 443; +}); + +// 添加安全头 +app.Use(async (context, next) => +{ + context.Response.Headers.Add("X-Content-Type-Options", "nosniff"); + context.Response.Headers.Add("X-Frame-Options", "DENY"); + context.Response.Headers.Add("X-XSS-Protection", "1; mode=block"); + context.Response.Headers.Add("Referrer-Policy", "no-referrer"); + await next(); +}); +``` + +## 11. 性能优化 + +### 11.1 数据库连接池 +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=takeout_saas;Username=user;Password=pass;Pooling=true;MinPoolSize=5;MaxPoolSize=100;ConnectionLifetime=300" + } +} +``` + +### 11.2 Redis连接池 +```csharp +services.AddStackExchangeRedisCache(options => +{ + options.Configuration = configuration["Redis:Configuration"]; + options.InstanceName = "TakeoutSaaS:"; +}); +``` + +### 11.3 响应压缩 +```csharp +builder.Services.AddResponseCompression(options => +{ + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); +}); +``` + +## 12. 故障恢复 + +### 12.1 数据库恢复 +```bash +# 从备份恢复 +pg_restore -h localhost -U takeout_user -d takeout_saas -v /backup/full_20240101.dump + +# PITR恢复到指定时间点 +# 1. 停止数据库 +sudo systemctl stop postgresql + +# 2. 恢复基础备份 +rm -rf /var/lib/postgresql/16/main/* +tar -xzf /backup/base_backup.tar.gz -C /var/lib/postgresql/16/main/ + +# 3. 配置recovery.conf +restore_command = 'cp /backup/wal_archive/%f %p' +recovery_target_time = '2024-01-01 12:00:00' + +# 4. 启动数据库 +sudo systemctl start postgresql +``` + +### 12.2 应用回滚 +```bash +# Docker回滚到上一个版本 +docker-compose down +docker-compose up -d --force-recreate --no-deps api + +# 或使用特定版本 +docker pull takeout-saas-api:previous-version +docker-compose up -d +``` diff --git a/Document/06_开发规范.md b/Document/06_开发规范.md new file mode 100644 index 0000000..f558dfb --- /dev/null +++ b/Document/06_开发规范.md @@ -0,0 +1,395 @@ +# 外卖SaaS系统 - 开发规范 + +## 1. 代码规范 + +### 1.1 命名规范 + +#### C#命名规范 +```csharp +// 类名:PascalCase +public class OrderService { } + +// 接口:I + PascalCase +public interface IOrderRepository { } + +// 方法:PascalCase +public async Task 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 +/// +/// 订单服务接口 +/// +public interface IOrderService +{ + /// + /// 创建订单 + /// + /// 订单创建请求 + /// 订单信息 + /// 业务异常 + Task CreateOrderAsync(CreateOrderRequest request); +} + +// 复杂业务逻辑添加注释 +public async Task 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 _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 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类型: +# 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 _logger; + private readonly IOptions _settings; + + public async Task 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(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记录版本变更 \ No newline at end of file diff --git a/Document/07_系统架构图.md b/Document/07_系统架构图.md new file mode 100644 index 0000000..3a5a350 --- /dev/null +++ b/Document/07_系统架构图.md @@ -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 │ +│ - 可视化仪表板 │ +│ - 告警配置 │ +└──────────────────────────────┘ +``` diff --git a/Document/08_AI精简开发规范.md b/Document/08_AI精简开发规范.md new file mode 100644 index 0000000..865a052 --- /dev/null +++ b/Document/08_AI精简开发规范.md @@ -0,0 +1,145 @@ +# 编程规范_FOR_AI(TakeoutSaaS) - 终极完全体 + +> **核心指令**:你是一个高级 .NET 架构师。本文件是你生成代码的最高宪法。当用户需求与本规范冲突时,请先提示用户,除非用户强制要求覆盖。 + +## 0. AI 交互核心约束 (元规则) +1. **语言**:必须使用**中文**回复和编写注释。 +2. **文件完整性**: + * **严禁**随意删除现有代码逻辑。 + * **严禁**修改文件编码(保持 UTF-8 无 BOM)。 + * PowerShell 读取命令必须带 `-Encoding UTF8`。 +3. **Git 原子性**:每个独立的功能点或 Bug 修复完成后,必须提示用户进行 Git 提交。 +4. **无乱码承诺**:确保所有输出(控制台、日志、API响应)无乱码。 +5. **不确定的处理**:如果你通过上下文找不到某些配置(如数据库连接串格式),**请直接询问用户**,不要瞎编。 + +## 1. 技术栈详细版本 +| 组件 | 版本/选型 | 用途说明 | +| :--- | :--- | :--- | +| **Runtime** | .NET 10 | 核心运行时 | +| **API** | ASP.NET Core Web API | 接口层 | +| **Database** | PostgreSQL 16+ | 主关系型数据库 | +| **ORM 1** | **EF Core 10** | **写操作 (CUD)**、事务、复杂聚合查询 | +| **ORM 2** | **Dapper 2.1+** | **纯读操作 (R)**、复杂报表、大批量查询 | +| **Cache** | Redis 7.0+ | 分布式缓存、Session | +| **MQ** | RabbitMQ 3.12+ | 异步解耦 (MassTransit) | +| **Libs** | MediatR, Serilog, FluentValidation | CQRS, 日志, 验证 | + +## 2. 命名与风格 (严格匹配) +* **C# 代码**: + * 类/接口/方法/属性:`PascalCase` (如 `OrderService`) + * **布尔属性**:必须加 `Is` 或 `Has` 前缀 (如 `IsDeleted`, `HasPayment`) + * 私有字段:`_camelCase` (如 `_orderRepository`) + * 参数/变量:`camelCase` (如 `orderId`) +* **PostgreSQL 数据库**: + * 表名:`snake_case` + **复数** (如 `merchant_orders`) + * 列名:`snake_case` (如 `order_no`, `is_active`) + * 主键:`id` (类型 `bigint`) +* **文件规则**: + * **一个文件一个类**。文件名必须与类名完全一致。 + +## 3. 分层架构 (Clean Architecture) +**你生成的代码必须严格归类到以下目录:** +* **`src/Api`**: 仅负责路由与 DTO 转换,**禁止**包含业务逻辑。 +* **`src/Application`**: 业务编排层。必须使用 **CQRS** (`IRequestHandler`) 和 **Mediator**。 +* **`src/Domain`**: 核心领域层。包含实体、枚举、领域异常。**禁止**依赖 EF Core 等外部库。 +* **`src/Infrastructure`**: 基础设施层。实现仓储、数据库上下文、第三方服务。 + +## 4. 注释与文档 +* **强制 XML 注释**:所有 `public` 的类、方法、属性必须有 ``。 +* **步骤注释**:超过 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. [ ] **配置硬编码**:是否直接写死了连接串或密钥? + +--- \ No newline at end of file diff --git a/Document/09_服务器文档.md b/Document/09_服务器文档.md new file mode 100644 index 0000000..a682d88 --- /dev/null +++ b/Document/09_服务器文档.md @@ -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 6379,RabbitMQ 5672/15672 等) +- 数据持久化与备份: 待补充 +- 监控与告警: 待补充 +- 变更记录: 待补充 diff --git a/Document/10_设计期DbContext配置指引.md b/Document/10_设计期DbContext配置指引.md new file mode 100644 index 0000000..2da0fd9 --- /dev/null +++ b/Document/10_设计期DbContext配置指引.md @@ -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`)按下面顺序解析连接串: +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。 diff --git a/Document/11_SystemTodo.md b/Document/11_SystemTodo.md new file mode 100644 index 0000000..9d9adbe --- /dev/null +++ b/Document/11_SystemTodo.md @@ -0,0 +1,65 @@ +# TODO Roadmap + +> 当前列表为原 11 号文档中的待办事项,已迁移到此处并统一以复选框形式标记。若无特殊说明,均尚未完成。 + +## 1. 配置与基础设施(高优) +- [x] Development/Production 数据库连接与 Secret 落地(Staging 暂不需要)。 +- [x] Redis 服务部署完毕并记录配置。 +- [x] RabbitMQ 服务部署完毕并记录配置。 +- [x] COS 密钥配置补录完毕。 +- [ ] OSS 密钥配置补录完毕(已忽略,待采购后再补录)。 +- [ ] SMS 平台密钥配置补录完毕(已忽略,待采购后再补录)。 +- [x] WeChat Mini 程序密钥配置补录完毕(AppID:wx30f91e6afe79f405,AppSecret:64324a7f604245301066ba7c3add488e,已同步到 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、TenantAwareDbContext),JWT 已写入 roles/permissions/tenant_id(JwtTokenService),PermissionAuthorize 已在 Admin API 使用,CurrentUserProfile 含角色/权限/租户;但仅有内嵌 string[] 权限存储,无角色/权限表与洞察查询,Swagger 缺少示例与多租户示例。 + - [x] 差距与步骤: + - [x] 增加权限/租户洞察查询(按用户、按租户分页)并确保带 tenant 过滤(TenantAwareDbContext 或 Dapper 参数化)。 + - [x] 输出可读的角色/权限列表(基于现有种子/配置的只读查询)。【已落地:RBAC1 模型 + 角色/权限管理 API;Swagger 示例后续补充】 + - [x] 为洞察接口和 /auth/profile 增加 Swagger 示例,包含 tenant_id、roles、permissions,展示 Bearer 示例与租户 Header 示例。 + - [ ] 若用 Dapper 读侧,SQL 必须参数化并显式过滤 tenant_id。 + - [x] 计划顺序:Step A 设计应用层洞察 DTO/Query;Step B Admin API 只读端点(Authorize/PermissionAuthorize);Step 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/File(ELK 待后续配置)。 +- [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 完整记录。 +- [ ] 运行手册包含部署步骤、资源拓扑、故障排查手册。 +- [ ] 安全合规模板覆盖数据分级、密钥管理、审计流程并形成可复用表格。 diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md new file mode 100644 index 0000000..bd3da81 --- /dev/null +++ b/Document/12_BusinessTodo.md @@ -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 配送范围及能力开关。 +- [ ] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIP(POST /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 监控与告警。 \ No newline at end of file diff --git a/Document/13_AppSeed说明.md b/Document/13_AppSeed说明.md new file mode 100644 index 0000000..95e2d0e --- /dev/null +++ b/Document/13_AppSeed说明.md @@ -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 ID;0 表示让雪花生成) +- `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`,避免误写入。 diff --git a/Document/14_OpenTelemetry接入指引.md b/Document/14_OpenTelemetry接入指引.md new file mode 100644 index 0000000..bdfcd50 --- /dev/null +++ b/Document/14_OpenTelemetry接入指引.md @@ -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 默认 true,Prod 建议 false +} +``` +- 环境变量可覆盖:`OTEL_SERVICE_NAME`、`OTEL_EXPORTER_OTLP_ENDPOINT` 等。 + +## 4. Collector/后端接入建议 +- Collector 监听 4317/4318(gRPC/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/开关后重启即可。 diff --git a/Document/API边界与自检清单.md b/Document/API边界与自检清单.md new file mode 100644 index 0000000..d1fa76d --- /dev/null +++ b/Document/API边界与自检清单.md @@ -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. UserApi(C 端用户) +- **面向对象**: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%)。 diff --git a/Document/CI_CD流水线.md b/Document/CI_CD流水线.md new file mode 100644 index 0000000..6be352f --- /dev/null +++ b/Document/CI_CD流水线.md @@ -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 端口约定 +- Admin:7801 +- Mini:7701 +- User:7901 + +## 完整流水线 YAML +```yaml +version: 1.0 +name: takeoutsaas-ci-cd +displayName: TakeoutSaaS CI/CD +triggers: + push: + branches: + include: + - master + +stages: + - stage: DetectChanges + name: DetectChanges + steps: + - step: Checkout + name: Checkout + checkout: self + - step: Detect + name: Detect + script: | + set -e + if [ "$GIT_BRANCH" != "master" ] || [ "$GIT_PREVIOUS_BRANCH" != "dev" ]; then + echo "非 dev->master,跳过流水线"; exit 0; fi + + git fetch origin master --depth=1 + BASE=${BASE_REF:-origin/master} + CHANGED=$(git diff --name-only "$(git merge-base $BASE HEAD)" HEAD) + echo "变更文件:" + echo "$CHANGED" + + deploy_all=false + services=() + hit(){ echo "$CHANGED" | grep -qE "$1"; } + + if hit '^src/(Domain|Application|Infrastructure|Core|Modules)/'; then deploy_all=true; fi + hit '^Directory.Build.props$' && deploy_all=true + + hit '^src/Api/TakeoutSaaS.AdminApi/' && services+=("admin-api") + hit '^src/Api/TakeoutSaaS.MiniApi/' && services+=("mini-api") + hit '^src/Api/TakeoutSaaS.UserApi/' && services+=("user-api") + + if $deploy_all || [ ${#services[@]} -eq 0 ]; then + services=("admin-api" "mini-api" "user-api") + fi + + echo "SERVICES=${services[*]}" >> "$ACROSS_STAGES_ENV_FILE" + + - stage: BuildPush + name: BuildPush + steps: + - step: DockerBuildPush + name: DockerBuildPush + script: | + set -e + IFS=' ' read -ra svcs <<< "$SERVICES" + REGISTRY=${REGISTRY:?需要配置 REGISTRY} + TAG=${TAG:-$(date +%Y%m%d%H%M%S)} + + echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin + + for svc in "${svcs[@]}"; do + case "$svc" in + admin-api) dockerfile="src/Api/TakeoutSaaS.AdminApi/Dockerfile"; image="$REGISTRY/admin-api:$TAG" ;; + mini-api) dockerfile="src/Api/TakeoutSaaS.MiniApi/Dockerfile"; image="$REGISTRY/mini-api:$TAG" ;; + user-api) dockerfile="src/Api/TakeoutSaaS.UserApi/Dockerfile"; image="$REGISTRY/user-api:$TAG" ;; + esac + echo "构建并推送 $image" + docker build -f "$dockerfile" -t "$image" . + docker push "$image" + done + echo "IMAGE_TAG=$TAG" >> "$ACROSS_STAGES_ENV_FILE" + + - stage: Deploy + name: Deploy + steps: + - step: DockerDeploy + name: DockerDeploy + script: | + set -e + command -v sshpass >/dev/null 2>&1 || (sudo apt-get update && sudo apt-get install -y sshpass) + + IFS=' ' read -ra svcs <<< "$SERVICES" + TAG="$IMAGE_TAG" + REGISTRY=${REGISTRY:?} + DEPLOY_HOST=${DEPLOY_HOST:?} + DEPLOY_USER=${DEPLOY_USER:-root} + DEPLOY_PASSWORD=${DEPLOY_PASSWORD:?} + + for svc in "${svcs[@]}"; do + case "$svc" in + admin-api) image="$REGISTRY/admin-api:$TAG"; port=7801 ;; + mini-api) image="$REGISTRY/mini-api:$TAG"; port=7701 ;; + user-api) image="$REGISTRY/user-api:$TAG"; port=7901 ;; + esac + + echo "部署 $svc -> $image" + sshpass -p "$DEPLOY_PASSWORD" ssh -o StrictHostKeyChecking=no "$DEPLOY_USER@$DEPLOY_HOST" "set -e; docker pull $image; docker stop $svc 2>/dev/null || true; docker rm $svc 2>/dev/null || true; docker run -d --name $svc --restart=always -p $port:$port $image" + done +``` + +## 注意事项 +- 以上 YAML 如仍报 YAML 校验错误,可将 `triggers` 改为: + ```yaml + on: + push: + branches: + - master + ``` + 其余保持不变。 +- 如果云效的分支变量名与 `GIT_BRANCH` / `GIT_PREVIOUS_BRANCH` 不同,请在 Detect 步骤替换为实际变量名。 +- 所有密码、密钥务必放在“密钥/凭据”类型变量中,不要写入代码库。 diff --git a/Document/README.md b/Document/README.md new file mode 100644 index 0000000..aa08578 --- /dev/null +++ b/Document/README.md @@ -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 diff --git a/Document/infra/postgres_redis.md b/Document/infra/postgres_redis.md new file mode 100644 index 0000000..bf6e076 --- /dev/null +++ b/Document/infra/postgres_redis.md @@ -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/配置当作“最终规范”确保环境一致性。 diff --git a/Document/xmind_小程序版模块规划.md b/Document/xmind_小程序版模块规划.md new file mode 100644 index 0000000..3af2007 --- /dev/null +++ b/Document/xmind_小程序版模块规划.md @@ -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:性能优化与缓存、运营大盘与细分报表、测试与文档完善、上线与监控 diff --git a/Document/xmind_小程序版模块规划.xmind b/Document/xmind_小程序版模块规划.xmind new file mode 100644 index 0000000..9df8073 Binary files /dev/null and b/Document/xmind_小程序版模块规划.xmind differ diff --git a/README.md b/README.md index 4bd4606..d82e93a 100644 --- a/README.md +++ b/README.md @@ -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 Swagger:http://localhost:5001/swagger +- 小程序/用户端 MiniApi Swagger:http://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 ## 协作者 -> 高效的协作会激发无尽的创造力,将他们的名字记录在这里吧 + +感谢所有为本项目做出贡献的开发者! + +--- + +⭐ 如果这个项目对你有帮助,请给我们一个星标! diff --git a/TakeoutSaaS.sln b/TakeoutSaaS.sln new file mode 100644 index 0000000..d924b98 --- /dev/null +++ b/TakeoutSaaS.sln @@ -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 diff --git a/deploy/postgres/README.md b/deploy/postgres/README.md new file mode 100644 index 0000000..04e7222 --- /dev/null +++ b/deploy/postgres/README.md @@ -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`,再运行脚本重新创建。 | diff --git a/deploy/postgres/bootstrap.ps1 b/deploy/postgres/bootstrap.ps1 new file mode 100644 index 0000000..3ea0bea --- /dev/null +++ b/deploy/postgres/bootstrap.ps1 @@ -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." diff --git a/deploy/postgres/create_databases.sql b/deploy/postgres/create_databases.sql new file mode 100644 index 0000000..c8732f6 --- /dev/null +++ b/deploy/postgres/create_databases.sql @@ -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; diff --git a/deploy/prometheus/alert.rules.yml b/deploy/prometheus/alert.rules.yml new file mode 100644 index 0000000..a74f845 --- /dev/null +++ b/deploy/prometheus/alert.rules.yml @@ -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 状态" diff --git a/deploy/prometheus/prometheus.yml b/deploy/prometheus/prometheus.yml new file mode 100644 index 0000000..3385f12 --- /dev/null +++ b/deploy/prometheus/prometheus.yml @@ -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 diff --git a/deploy/redis/README.md b/deploy/redis/README.md new file mode 100644 index 0000000..c955032 --- /dev/null +++ b/deploy/redis/README.md @@ -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 -a ping\\ 可访问。\n\n## 2. 配置说明\n\n- \\ equirepass\\ 已设置为 MsuMshk112233。\n- 启用 appendonly(AOF),并每秒 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` +- AOF 文件:`appendonly.aof` + +通过 `redis-cli -a save` 或 `bgsave` 触发。确保备份目录已纳入快照/对象存储。 + diff --git a/deploy/redis/redis.conf b/deploy/redis/redis.conf new file mode 100644 index 0000000..5d20bb0 --- /dev/null +++ b/deploy/redis/redis.conf @@ -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 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs new file mode 100644 index 0000000..72f23f4 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs @@ -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; + +/// +/// 管理后台认证接口 +/// +/// +/// +/// +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/auth")] +public sealed class AuthController(IAdminAuthService authService) : BaseApiController +{ + + + /// + /// 登录获取 Token + /// + [HttpPost("login")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Login([FromBody] AdminLoginRequest request, CancellationToken cancellationToken) + { + var response = await authService.LoginAsync(request, cancellationToken); + return ApiResponse.Ok(response); + } + + /// + /// 刷新 Token + /// + [HttpPost("refresh")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) + { + var response = await authService.RefreshTokenAsync(request, cancellationToken); + return ApiResponse.Ok(response); + } + + /// + /// 获取当前用户信息 + /// + /// + /// 示例: + /// + /// GET /api/admin/v1/auth/profile + /// Header: Authorization: Bearer <JWT> + /// 响应: + /// { + /// "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" + /// } + /// } + /// + /// + [HttpGet("profile")] + [PermissionAuthorize("identity:profile:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + public async Task> GetProfile(CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + if (userId == 0) + { + return ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"); + } + + var profile = await authService.GetProfileAsync(userId, cancellationToken); + return ApiResponse.Ok(profile); + } + + /// + /// 查询指定用户的角色与权限概览(当前租户范围)。 + /// + /// + /// 示例: + /// + /// GET /api/admin/v1/auth/permissions/900123456789012346 + /// Header: Authorization: Bearer <JWT> + /// 响应: + /// { + /// "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" + /// } + /// } + /// + /// + [HttpGet("permissions/{userId:long}")] + [PermissionAuthorize("identity:permission:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> GetUserPermissions(long userId, CancellationToken cancellationToken) + { + var result = await authService.GetUserPermissionsAsync(userId, cancellationToken); + return result is null + ? ApiResponse.Error(ErrorCodes.NotFound, "用户不存在或不属于当前租户") + : ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs new file mode 100644 index 0000000..fe5d2cf --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs @@ -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; + +/// +/// 配送单管理。 +/// +/// +/// 初始化控制器。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/deliveries")] +public sealed class DeliveriesController(IMediator mediator) : BaseApiController +{ + + + /// + /// 创建配送单。 + /// + [HttpPost] + [PermissionAuthorize("delivery:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody] CreateDeliveryOrderCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询配送单列表。 + /// + [HttpGet] + [PermissionAuthorize("delivery:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } + + /// + /// 获取配送单详情。 + /// + [HttpGet("{deliveryOrderId:long}")] + [PermissionAuthorize("delivery:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long deliveryOrderId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetDeliveryOrderByIdQuery { DeliveryOrderId = deliveryOrderId }, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "配送单不存在") + : ApiResponse.Ok(result); + } + + /// + /// 更新配送单。 + /// + [HttpPut("{deliveryOrderId:long}")] + [PermissionAuthorize("delivery:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> 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.Error(ErrorCodes.NotFound, "配送单不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除配送单。 + /// + [HttpDelete("{deliveryOrderId:long}")] + [PermissionAuthorize("delivery:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long deliveryOrderId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteDeliveryOrderCommand { DeliveryOrderId = deliveryOrderId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "配送单不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs new file mode 100644 index 0000000..d98e420 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs @@ -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; + +/// +/// 参数字典管理。 +/// +/// +/// 初始化字典控制器。 +/// +/// 字典服务 +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/dictionaries")] +public sealed class DictionaryController(IDictionaryAppService dictionaryAppService) : BaseApiController +{ + + + /// + /// 查询字典分组。 + /// + [HttpGet] + [PermissionAuthorize("dictionary:group:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetGroups([FromQuery] DictionaryGroupQuery query, CancellationToken cancellationToken) + { + var groups = await dictionaryAppService.SearchGroupsAsync(query, cancellationToken); + return ApiResponse>.Ok(groups); + } + + /// + /// 创建字典分组。 + /// + [HttpPost] + [PermissionAuthorize("dictionary:group:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateGroup([FromBody] CreateDictionaryGroupRequest request, CancellationToken cancellationToken) + { + var group = await dictionaryAppService.CreateGroupAsync(request, cancellationToken); + return ApiResponse.Ok(group); + } + + /// + /// 更新字典分组。 + /// + [HttpPut("{groupId:long}")] + [PermissionAuthorize("dictionary:group:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpdateGroup(long groupId, [FromBody] UpdateDictionaryGroupRequest request, CancellationToken cancellationToken) + { + var group = await dictionaryAppService.UpdateGroupAsync(groupId, request, cancellationToken); + return ApiResponse.Ok(group); + } + + /// + /// 删除字典分组。 + /// + [HttpDelete("{groupId:long}")] + [PermissionAuthorize("dictionary:group:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DeleteGroup(long groupId, CancellationToken cancellationToken) + { + await dictionaryAppService.DeleteGroupAsync(groupId, cancellationToken); + return ApiResponse.Success(); + } + + /// + /// 创建字典项。 + /// + [HttpPost("{groupId:long}/items")] + [PermissionAuthorize("dictionary:item:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateItem(long groupId, [FromBody] CreateDictionaryItemRequest request, CancellationToken cancellationToken) + { + request.GroupId = groupId; + var item = await dictionaryAppService.CreateItemAsync(request, cancellationToken); + return ApiResponse.Ok(item); + } + + /// + /// 更新字典项。 + /// + [HttpPut("items/{itemId:long}")] + [PermissionAuthorize("dictionary:item:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpdateItem(long itemId, [FromBody] UpdateDictionaryItemRequest request, CancellationToken cancellationToken) + { + var item = await dictionaryAppService.UpdateItemAsync(itemId, request, cancellationToken); + return ApiResponse.Ok(item); + } + + /// + /// 删除字典项。 + /// + [HttpDelete("items/{itemId:long}")] + [PermissionAuthorize("dictionary:item:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DeleteItem(long itemId, CancellationToken cancellationToken) + { + await dictionaryAppService.DeleteItemAsync(itemId, cancellationToken); + return ApiResponse.Success(); + } + + /// + /// 批量获取字典项(命中缓存)。 + /// + [HttpPost("batch")] + [ProducesResponseType(typeof(ApiResponse>>), StatusCodes.Status200OK)] + public async Task>>> BatchGet([FromBody] DictionaryBatchQueryRequest request, CancellationToken cancellationToken) + { + var dictionaries = await dictionaryAppService.GetCachedItemsAsync(request, cancellationToken); + return ApiResponse>>.Ok(dictionaries); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs new file mode 100644 index 0000000..f53d344 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs @@ -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; + +/// +/// 管理后台文件上传。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/files")] +public sealed class FilesController(IFileStorageService fileStorageService) : BaseApiController +{ + private readonly IFileStorageService _fileStorageService = fileStorageService; + + /// + /// 上传图片或文件。 + /// + [HttpPost("upload")] + [RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + public async Task> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken) + { + if (file == null || file.Length == 0) + { + return ApiResponse.Error(ErrorCodes.BadRequest, "文件不能为空"); + } + + if (!UploadFileTypeParser.TryParse(type, out var uploadType)) + { + return ApiResponse.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.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs new file mode 100644 index 0000000..4db5f17 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs @@ -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; + +/// +/// 管理后台 - 健康检查。 +/// +[ApiVersion("1.0")] +[Route("api/admin/v{version:apiVersion}/[controller]")] +public class HealthController : BaseApiController +{ + /// + /// 获取服务健康状态。 + /// + /// 健康状态 + [HttpGet] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public ApiResponse Get() + { + var payload = new { status = "OK", service = "AdminApi", time = DateTime.UtcNow }; + return ApiResponse.Ok(payload); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs new file mode 100644 index 0000000..dd99bb6 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs @@ -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; + +/// +/// 商户管理。 +/// +/// +/// 初始化控制器。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/merchants")] +public sealed class MerchantsController(IMediator mediator) : BaseApiController +{ + + + /// + /// 创建商户。 + /// + [HttpPost] + [PermissionAuthorize("merchant:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody] CreateMerchantCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询商户列表。 + /// + [HttpGet] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } + + /// + /// 更新商户。 + /// + [HttpPut("{merchantId:long}")] + [PermissionAuthorize("merchant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> 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.Error(ErrorCodes.NotFound, "商户不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除商户。 + /// + [HttpDelete("{merchantId:long}")] + [PermissionAuthorize("merchant:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long merchantId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteMerchantCommand { MerchantId = merchantId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "商户不存在"); + } + + /// + /// 获取商户详情。 + /// + [HttpGet("{merchantId:long}")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long merchantId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMerchantByIdQuery { MerchantId = merchantId }, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "商户不存在") + : ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs new file mode 100644 index 0000000..4d04390 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs @@ -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; + +/// +/// 订单管理。 +/// +/// +/// 初始化控制器。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/orders")] +public sealed class OrdersController(IMediator mediator) : BaseApiController +{ + + + /// + /// 创建订单。 + /// + [HttpPost] + [PermissionAuthorize("order:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody] CreateOrderCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询订单列表。 + /// + [HttpGet] + [PermissionAuthorize("order:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } + + /// + /// 获取订单详情。 + /// + [HttpGet("{orderId:long}")] + [PermissionAuthorize("order:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long orderId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetOrderByIdQuery { OrderId = orderId }, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "订单不存在") + : ApiResponse.Ok(result); + } + + /// + /// 更新订单。 + /// + [HttpPut("{orderId:long}")] + [PermissionAuthorize("order:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> 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.Error(ErrorCodes.NotFound, "订单不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除订单。 + /// + [HttpDelete("{orderId:long}")] + [PermissionAuthorize("order:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long orderId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteOrderCommand { OrderId = orderId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "订单不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs new file mode 100644 index 0000000..de8f322 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs @@ -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; + +/// +/// 支付记录管理。 +/// +/// +/// 初始化控制器。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/payments")] +public sealed class PaymentsController(IMediator mediator) : BaseApiController +{ + + + /// + /// 创建支付记录。 + /// + [HttpPost] + [PermissionAuthorize("payment:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody] CreatePaymentCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询支付记录列表。 + /// + [HttpGet] + [PermissionAuthorize("payment:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } + + /// + /// 获取支付记录详情。 + /// + [HttpGet("{paymentId:long}")] + [PermissionAuthorize("payment:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long paymentId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPaymentByIdQuery { PaymentId = paymentId }, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "支付记录不存在") + : ApiResponse.Ok(result); + } + + /// + /// 更新支付记录。 + /// + [HttpPut("{paymentId:long}")] + [PermissionAuthorize("payment:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> 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.Error(ErrorCodes.NotFound, "支付记录不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除支付记录。 + /// + [HttpDelete("{paymentId:long}")] + [PermissionAuthorize("payment:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long paymentId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeletePaymentCommand { PaymentId = paymentId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "支付记录不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs new file mode 100644 index 0000000..26b8518 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs @@ -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; + +/// +/// 权限管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/permissions")] +public sealed class PermissionsController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询权限。 + /// + /// + /// 示例:GET /api/admin/v1/permissions?keyword=order&page=1&pageSize=20 + /// + [HttpGet] + [PermissionAuthorize("identity:permission:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search([FromQuery] SearchPermissionsQuery query, CancellationToken cancellationToken) + { + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 创建权限。 + /// + [HttpPost] + [PermissionAuthorize("identity:permission:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody, Required] CreatePermissionCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新权限。 + /// + [HttpPut("{permissionId:long}")] + [PermissionAuthorize("identity:permission:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long permissionId, [FromBody, Required] UpdatePermissionCommand command, CancellationToken cancellationToken) + { + command = command with { PermissionId = permissionId }; + var result = await mediator.Send(command, cancellationToken); + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "权限不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除权限。 + /// + [HttpDelete("{permissionId:long}")] + [PermissionAuthorize("identity:permission:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Delete(long permissionId, CancellationToken cancellationToken) + { + var command = new DeletePermissionCommand { PermissionId = permissionId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs new file mode 100644 index 0000000..24e334c --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs @@ -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; + +/// +/// 商品管理。 +/// +/// +/// 初始化控制器。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/products")] +public sealed class ProductsController(IMediator mediator) : BaseApiController +{ + + + /// + /// 创建商品。 + /// + [HttpPost] + [PermissionAuthorize("product:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody] CreateProductCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询商品列表。 + /// + [HttpGet] + [PermissionAuthorize("product:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } + + /// + /// 获取商品详情。 + /// + [HttpGet("{productId:long}")] + [PermissionAuthorize("product:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long productId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetProductByIdQuery { ProductId = productId }, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") + : ApiResponse.Ok(result); + } + + /// + /// 更新商品。 + /// + [HttpPut("{productId:long}")] + [PermissionAuthorize("product:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> 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.Error(ErrorCodes.NotFound, "商品不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除商品。 + /// + [HttpDelete("{productId:long}")] + [PermissionAuthorize("product:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long productId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteProductCommand { ProductId = productId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "商品不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs new file mode 100644 index 0000000..a9f7148 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs @@ -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; + +/// +/// 角色管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/roles")] +public sealed class RolesController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询角色。 + /// + /// + /// 示例: + /// GET /api/admin/v1/roles?keyword=ops&page=1&pageSize=20 + /// Header: Authorization: Bearer <JWT> + X-Tenant-Id + /// + [HttpGet] + [PermissionAuthorize("identity:role:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search([FromQuery] SearchRolesQuery query, CancellationToken cancellationToken) + { + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 创建角色。 + /// + [HttpPost] + [PermissionAuthorize("identity:role:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody, Required] CreateRoleCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新角色。 + /// + [HttpPut("{roleId:long}")] + [PermissionAuthorize("identity:role:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> 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.Error(StatusCodes.Status404NotFound, "角色不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除角色。 + /// + [HttpDelete("{roleId:long}")] + [PermissionAuthorize("identity:role:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Delete(long roleId, CancellationToken cancellationToken) + { + var command = new DeleteRoleCommand { RoleId = roleId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 绑定角色权限(覆盖式)。 + /// + [HttpPut("{roleId:long}/permissions")] + [PermissionAuthorize("identity:role:bind-permission")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> BindPermissions(long roleId, [FromBody, Required] BindRolePermissionsCommand command, CancellationToken cancellationToken) + { + command = command with { RoleId = roleId }; + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs new file mode 100644 index 0000000..6abdac2 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs @@ -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; + +/// +/// 门店管理。 +/// +/// +/// 初始化控制器。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/stores")] +public sealed class StoresController(IMediator mediator) : BaseApiController +{ + + + /// + /// 创建门店。 + /// + [HttpPost] + [PermissionAuthorize("store:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody] CreateStoreCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询门店列表。 + /// + [HttpGet] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } + + /// + /// 获取门店详情。 + /// + [HttpGet("{storeId:long}")] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetStoreByIdQuery { StoreId = storeId }, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "门店不存在") + : ApiResponse.Ok(result); + } + + /// + /// 更新门店。 + /// + [HttpPut("{storeId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> 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.Error(ErrorCodes.NotFound, "门店不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除门店。 + /// + [HttpDelete("{storeId:long}")] + [PermissionAuthorize("store:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long storeId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreCommand { StoreId = storeId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "门店不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs new file mode 100644 index 0000000..045d649 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs @@ -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; + +/// +/// 系统参数管理。 +/// +/// +/// 提供参数的新增、修改、查询与删除。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/system-parameters")] +public sealed class SystemParametersController(IMediator mediator) : BaseApiController +{ + /// + /// 创建系统参数。 + /// + [HttpPost] + [PermissionAuthorize("system-parameter:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody] CreateSystemParameterCommand command, CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询系统参数列表。 + /// + [HttpGet] + [PermissionAuthorize("system-parameter:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> 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>.Ok(result); + } + + /// + /// 获取系统参数详情。 + /// + [HttpGet("{parameterId:long}")] + [PermissionAuthorize("system-parameter:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long parameterId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetSystemParameterByIdQuery(parameterId), cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "系统参数不存在") + : ApiResponse.Ok(result); + } + + /// + /// 更新系统参数。 + /// + [HttpPut("{parameterId:long}")] + [PermissionAuthorize("system-parameter:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> 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.Error(ErrorCodes.NotFound, "系统参数不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除系统参数。 + /// + [HttpDelete("{parameterId:long}")] + [PermissionAuthorize("system-parameter:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long parameterId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteSystemParameterCommand { ParameterId = parameterId }, cancellationToken); + return success + ? ApiResponse.Success() + : ApiResponse.Error(ErrorCodes.NotFound, "系统参数不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs new file mode 100644 index 0000000..328499a --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs @@ -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; + +/// +/// 用户权限洞察接口。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/users/permissions")] +public sealed class UserPermissionsController(IAdminAuthService authService) : BaseApiController +{ + /// + /// 分页查询当前租户用户的角色与权限概览。 + /// + /// + /// 示例: + /// + /// GET /api/admin/v1/users/permissions?keyword=ops&page=1&pageSize=20&sortBy=createdAt&sortDescending=true + /// Header: Authorization: Bearer <JWT> + /// 响应: + /// { + /// "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 + /// } + /// } + /// + /// + [HttpGet] + [PermissionAuthorize("identity:permission:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search( + [FromQuery] SearchUserPermissionsQuery query, + CancellationToken cancellationToken) + { + var result = await authService.SearchUserPermissionsAsync( + query.Keyword, + query.Page, + query.PageSize, + query.SortBy, + query.SortDescending, + cancellationToken); + + return ApiResponse>.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Dockerfile b/src/Api/TakeoutSaaS.AdminApi/Dockerfile new file mode 100644 index 0000000..27f5733 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Dockerfile @@ -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"] diff --git a/src/Api/TakeoutSaaS.AdminApi/Program.cs b/src/Api/TakeoutSaaS.AdminApi/Program.cs new file mode 100644 index 0000000..32b6036 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Program.cs @@ -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("Endpoint"); +var useConsoleExporter = otelSection.GetValue("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(); + 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(); +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Properties/launchSettings.json b/src/Api/TakeoutSaaS.AdminApi/Properties/launchSettings.json new file mode 100644 index 0000000..efda216 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "TakeoutSaaS.AdminApi": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:2676;http://localhost:2680" + } + } +} \ No newline at end of file diff --git a/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj b/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj new file mode 100644 index 0000000..57391fc Binary files /dev/null and b/src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj differ diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json new file mode 100644 index 0000000..1bc5044 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json @@ -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 + } +} \ No newline at end of file diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json new file mode 100644 index 0000000..1bc5044 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json @@ -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 + } +} \ No newline at end of file diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json new file mode 100644 index 0000000..3079e81 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -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:*" ] + } + ] + } + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/.gitkeep b/src/Api/TakeoutSaaS.MiniApi/Controllers/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs new file mode 100644 index 0000000..332dec2 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs @@ -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; + +/// +/// 小程序登录认证 +/// +/// +/// 小程序登录认证 +/// +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/mini/v{version:apiVersion}/auth")] +public sealed class AuthController(IMiniAuthService authService) : BaseApiController +{ + + + /// + /// 微信登录 + /// + [HttpPost("wechat/login")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken) + { + var response = await authService.LoginWithWeChatAsync(request, cancellationToken); + return ApiResponse.Ok(response); + } + + /// + /// 刷新 Token + /// + [HttpPost("refresh")] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) + { + var response = await authService.RefreshTokenAsync(request, cancellationToken); + return ApiResponse.Ok(response); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs new file mode 100644 index 0000000..a795c9e --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs @@ -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; + +/// +/// 小程序文件上传。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/mini/v{version:apiVersion}/files")] +public sealed class FilesController(IFileStorageService fileStorageService) : BaseApiController +{ + private readonly IFileStorageService _fileStorageService = fileStorageService; + + /// + /// 上传图片或文件。 + /// + [HttpPost("upload")] + [RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] + public async Task> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken) + { + if (file == null || file.Length == 0) + { + return ApiResponse.Error(ErrorCodes.BadRequest, "文件不能为空"); + } + + if (!UploadFileTypeParser.TryParse(type, out var uploadType)) + { + return ApiResponse.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.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs new file mode 100644 index 0000000..c2673f8 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs @@ -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; + +/// +/// 小程序端 - 健康检查。 +/// +[ApiVersion("1.0")] +[AllowAnonymous] +[Route("api/mini/v{version:apiVersion}/[controller]")] +public class HealthController : BaseApiController +{ + /// + /// 获取服务健康状态。 + /// + /// 健康状态 + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public ApiResponse Get() + { + var payload = new { status = "OK", service = "MiniApi", time = DateTime.UtcNow }; + return ApiResponse.Ok(payload); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs new file mode 100644 index 0000000..4bd29e4 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs @@ -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; + +/// +/// 当前用户信息 +/// +/// +/// +/// +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/mini/v{version:apiVersion}/me")] +public sealed class MeController(IMiniAuthService authService) : BaseApiController +{ + + + /// + /// 获取用户档案 + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + public async Task> Get(CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + if (userId == 0) + { + return ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"); + } + + var profile = await authService.GetProfileAsync(userId, cancellationToken); + return ApiResponse.Ok(profile); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Dockerfile b/src/Api/TakeoutSaaS.MiniApi/Dockerfile new file mode 100644 index 0000000..f737a92 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Dockerfile @@ -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"] diff --git a/src/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs new file mode 100644 index 0000000..34d6a45 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs @@ -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("Endpoint"); +var useConsoleExporter = otelSection.GetValue("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(); + 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(); +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Properties/launchSettings.json b/src/Api/TakeoutSaaS.MiniApi/Properties/launchSettings.json new file mode 100644 index 0000000..c959907 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "TakeoutSaaS.MiniApi": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:2678;http://localhost:2681" + } + } +} \ No newline at end of file diff --git a/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj new file mode 100644 index 0000000..0b5e0ba Binary files /dev/null and b/src/Api/TakeoutSaaS.MiniApi/TakeoutSaaS.MiniApi.csproj differ diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json new file mode 100644 index 0000000..8eb84b7 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json @@ -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 + } +} \ No newline at end of file diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json new file mode 100644 index 0000000..8eb84b7 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json @@ -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 + } +} \ No newline at end of file diff --git a/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs new file mode 100644 index 0000000..e1fa6d5 --- /dev/null +++ b/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs @@ -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; + +/// +/// 用户端 - 健康检查。 +/// +[ApiVersion("1.0")] +[AllowAnonymous] +[Route("api/user/v{version:apiVersion}/[controller]")] +public class HealthController : BaseApiController +{ + /// + /// 获取服务健康状态。 + /// + /// 健康状态 + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public ApiResponse Get() + { + var payload = new { status = "OK", service = "UserApi", time = DateTime.UtcNow }; + return ApiResponse.Ok(payload); + } +} diff --git a/src/Api/TakeoutSaaS.UserApi/Dockerfile b/src/Api/TakeoutSaaS.UserApi/Dockerfile new file mode 100644 index 0000000..22e0ab8 --- /dev/null +++ b/src/Api/TakeoutSaaS.UserApi/Dockerfile @@ -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"] diff --git a/src/Api/TakeoutSaaS.UserApi/Program.cs b/src/Api/TakeoutSaaS.UserApi/Program.cs new file mode 100644 index 0000000..98ed208 --- /dev/null +++ b/src/Api/TakeoutSaaS.UserApi/Program.cs @@ -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("Endpoint"); +var useConsoleExporter = otelSection.GetValue("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(); + 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(); +} diff --git a/src/Api/TakeoutSaaS.UserApi/Properties/launchSettings.json b/src/Api/TakeoutSaaS.UserApi/Properties/launchSettings.json new file mode 100644 index 0000000..8439ade --- /dev/null +++ b/src/Api/TakeoutSaaS.UserApi/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "TakeoutSaaS.UserApi": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:2679;http://localhost:2682" + } + } +} \ No newline at end of file diff --git a/src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj b/src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj new file mode 100644 index 0000000..b5f3476 Binary files /dev/null and b/src/Api/TakeoutSaaS.UserApi/TakeoutSaaS.UserApi.csproj differ diff --git a/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json b/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json new file mode 100644 index 0000000..585bef6 --- /dev/null +++ b/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json @@ -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 + } +} \ No newline at end of file diff --git a/src/Api/TakeoutSaaS.UserApi/appsettings.Production.json b/src/Api/TakeoutSaaS.UserApi/appsettings.Production.json new file mode 100644 index 0000000..585bef6 --- /dev/null +++ b/src/Api/TakeoutSaaS.UserApi/appsettings.Production.json @@ -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 + } +} \ No newline at end of file diff --git a/src/Application/TakeoutSaaS.Application/App/Common/Behaviors/ValidationBehavior.cs b/src/Application/TakeoutSaaS.Application/App/Common/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..177ebd9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Common/Behaviors/ValidationBehavior.cs @@ -0,0 +1,35 @@ +using FluentValidation; +using MediatR; + +namespace TakeoutSaaS.Application.App.Common.Behaviors; + +/// +/// MediatR 请求验证行为,统一触发 FluentValidation。 +/// +/// 请求类型。 +/// 响应类型。 +public sealed class ValidationBehavior(IEnumerable> validators) : IPipelineBehavior + where TRequest : notnull, IRequest +{ + private readonly IEnumerable> _validators = validators; + + /// + /// 执行验证并在通过时继续后续处理。 + /// + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (_validators.Any()) + { + var context = new ValidationContext(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(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Commands/CreateDeliveryOrderCommand.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Commands/CreateDeliveryOrderCommand.cs new file mode 100644 index 0000000..8f5a2b7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Commands/CreateDeliveryOrderCommand.cs @@ -0,0 +1,66 @@ +using MediatR; +using TakeoutSaaS.Application.App.Deliveries.Dto; +using TakeoutSaaS.Domain.Deliveries.Enums; + +namespace TakeoutSaaS.Application.App.Deliveries.Commands; + +/// +/// 创建配送单命令。 +/// +public sealed class CreateDeliveryOrderCommand : IRequest +{ + /// + /// 订单 ID。 + /// + public long OrderId { get; set; } + + /// + /// 服务商。 + /// + public DeliveryProvider Provider { get; set; } = DeliveryProvider.InHouse; + + /// + /// 第三方单号。 + /// + public string? ProviderOrderId { get; set; } + + /// + /// 状态。 + /// + public DeliveryStatus Status { get; set; } = DeliveryStatus.Pending; + + /// + /// 配送费。 + /// + public decimal? DeliveryFee { get; set; } + + /// + /// 骑手姓名。 + /// + public string? CourierName { get; set; } + + /// + /// 骑手电话。 + /// + public string? CourierPhone { get; set; } + + /// + /// 下发时间。 + /// + public DateTime? DispatchedAt { get; set; } + + /// + /// 取餐时间。 + /// + public DateTime? PickedUpAt { get; set; } + + /// + /// 完成时间。 + /// + public DateTime? DeliveredAt { get; set; } + + /// + /// 异常原因。 + /// + public string? FailureReason { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Commands/DeleteDeliveryOrderCommand.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Commands/DeleteDeliveryOrderCommand.cs new file mode 100644 index 0000000..b05162d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Commands/DeleteDeliveryOrderCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Deliveries.Commands; + +/// +/// 删除配送单命令。 +/// +public sealed class DeleteDeliveryOrderCommand : IRequest +{ + /// + /// 配送单 ID。 + /// + public long DeliveryOrderId { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Commands/UpdateDeliveryOrderCommand.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Commands/UpdateDeliveryOrderCommand.cs new file mode 100644 index 0000000..cb94be2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Commands/UpdateDeliveryOrderCommand.cs @@ -0,0 +1,71 @@ +using MediatR; +using TakeoutSaaS.Application.App.Deliveries.Dto; +using TakeoutSaaS.Domain.Deliveries.Enums; + +namespace TakeoutSaaS.Application.App.Deliveries.Commands; + +/// +/// 更新配送单命令。 +/// +public sealed record UpdateDeliveryOrderCommand : IRequest +{ + /// + /// 配送单 ID。 + /// + public long DeliveryOrderId { get; init; } + + /// + /// 订单 ID。 + /// + public long OrderId { get; init; } + + /// + /// 服务商。 + /// + public DeliveryProvider Provider { get; init; } = DeliveryProvider.InHouse; + + /// + /// 第三方单号。 + /// + public string? ProviderOrderId { get; init; } + + /// + /// 状态。 + /// + public DeliveryStatus Status { get; init; } = DeliveryStatus.Pending; + + /// + /// 配送费。 + /// + public decimal? DeliveryFee { get; init; } + + /// + /// 骑手姓名。 + /// + public string? CourierName { get; init; } + + /// + /// 骑手电话。 + /// + public string? CourierPhone { get; init; } + + /// + /// 下发时间。 + /// + public DateTime? DispatchedAt { get; init; } + + /// + /// 取餐时间。 + /// + public DateTime? PickedUpAt { get; init; } + + /// + /// 完成时间。 + /// + public DateTime? DeliveredAt { get; init; } + + /// + /// 异常原因。 + /// + public string? FailureReason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Dto/DeliveryEventDto.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Dto/DeliveryEventDto.cs new file mode 100644 index 0000000..a8d2a7e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Dto/DeliveryEventDto.cs @@ -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; + +/// +/// 配送事件 DTO。 +/// +public sealed class DeliveryEventDto +{ + /// + /// 事件 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 配送单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long DeliveryOrderId { get; init; } + + /// + /// 事件类型。 + /// + public DeliveryEventType EventType { get; init; } + + /// + /// 描述。 + /// + public string? Message { get; init; } + + /// + /// 事件时间。 + /// + public DateTime OccurredAt { get; init; } + + /// + /// 原始载荷。 + /// + public string? Payload { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Dto/DeliveryOrderDto.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Dto/DeliveryOrderDto.cs new file mode 100644 index 0000000..641d67a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Dto/DeliveryOrderDto.cs @@ -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; + +/// +/// 配送单 DTO。 +/// +public sealed class DeliveryOrderDto +{ + /// + /// 配送单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 订单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long OrderId { get; init; } + + /// + /// 配送服务商。 + /// + public DeliveryProvider Provider { get; init; } + + /// + /// 第三方配送单号。 + /// + public string? ProviderOrderId { get; init; } + + /// + /// 状态。 + /// + public DeliveryStatus Status { get; init; } + + /// + /// 配送费。 + /// + public decimal? DeliveryFee { get; init; } + + /// + /// 骑手姓名。 + /// + public string? CourierName { get; init; } + + /// + /// 骑手电话。 + /// + public string? CourierPhone { get; init; } + + /// + /// 下发时间。 + /// + public DateTime? DispatchedAt { get; init; } + + /// + /// 取餐时间。 + /// + public DateTime? PickedUpAt { get; init; } + + /// + /// 完成时间。 + /// + public DateTime? DeliveredAt { get; init; } + + /// + /// 异常原因。 + /// + public string? FailureReason { get; init; } + + /// + /// 事件列表。 + /// + public IReadOnlyList Events { get; init; } = Array.Empty(); + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/CreateDeliveryOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/CreateDeliveryOrderCommandHandler.cs new file mode 100644 index 0000000..1a67240 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/CreateDeliveryOrderCommandHandler.cs @@ -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; + +/// +/// 创建配送单命令处理器。 +/// +public sealed class CreateDeliveryOrderCommandHandler(IDeliveryRepository deliveryRepository, ILogger logger) + : IRequestHandler +{ + private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreateDeliveryOrderCommand request, CancellationToken cancellationToken) + { + 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 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() + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/DeleteDeliveryOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/DeleteDeliveryOrderCommandHandler.cs new file mode 100644 index 0000000..40974f1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/DeleteDeliveryOrderCommandHandler.cs @@ -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; + +/// +/// 删除配送单命令处理器。 +/// +public sealed class DeleteDeliveryOrderCommandHandler( + IDeliveryRepository deliveryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeleteDeliveryOrderCommand request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); + if (existing == null) + { + return false; + } + + await _deliveryRepository.DeleteDeliveryOrderAsync(request.DeliveryOrderId, tenantId, cancellationToken); + await _deliveryRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除配送单 {DeliveryOrderId}", request.DeliveryOrderId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/GetDeliveryOrderByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/GetDeliveryOrderByIdQueryHandler.cs new file mode 100644 index 0000000..24e425f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/GetDeliveryOrderByIdQueryHandler.cs @@ -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; + +/// +/// 配送单详情查询处理器。 +/// +public sealed class GetDeliveryOrderByIdQueryHandler( + IDeliveryRepository deliveryRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task Handle(GetDeliveryOrderByIdQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var order = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); + 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 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() + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/SearchDeliveryOrdersQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/SearchDeliveryOrdersQueryHandler.cs new file mode 100644 index 0000000..cc748db --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/SearchDeliveryOrdersQueryHandler.cs @@ -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; + +/// +/// 配送单列表查询处理器。 +/// +public sealed class SearchDeliveryOrdersQueryHandler( + IDeliveryRepository deliveryRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(SearchDeliveryOrdersQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var orders = await _deliveryRepository.SearchAsync(tenantId, request.Status, request.OrderId, cancellationToken); + + 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(items, request.Page, request.PageSize, orders.Count); + } + + private static IOrderedEnumerable ApplySorting( + IReadOnlyCollection 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) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/UpdateDeliveryOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/UpdateDeliveryOrderCommandHandler.cs new file mode 100644 index 0000000..c8df929 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/UpdateDeliveryOrderCommandHandler.cs @@ -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; + +/// +/// 更新配送单命令处理器。 +/// +public sealed class UpdateDeliveryOrderCommandHandler( + IDeliveryRepository deliveryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdateDeliveryOrderCommand request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); + 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 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() + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Queries/GetDeliveryOrderByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Queries/GetDeliveryOrderByIdQuery.cs new file mode 100644 index 0000000..0b89cae --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Queries/GetDeliveryOrderByIdQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Deliveries.Dto; + +namespace TakeoutSaaS.Application.App.Deliveries.Queries; + +/// +/// 配送单详情查询。 +/// +public sealed class GetDeliveryOrderByIdQuery : IRequest +{ + /// + /// 配送单 ID。 + /// + public long DeliveryOrderId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Queries/SearchDeliveryOrdersQuery.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Queries/SearchDeliveryOrdersQuery.cs new file mode 100644 index 0000000..751d90c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Queries/SearchDeliveryOrdersQuery.cs @@ -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; + +/// +/// 配送单列表查询。 +/// +public sealed class SearchDeliveryOrdersQuery : IRequest> +{ + /// + /// 订单 ID(可选)。 + /// + public long? OrderId { get; init; } + + /// + /// 配送状态。 + /// + public DeliveryStatus? Status { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段(createdAt/status/provider)。 + /// + public string? SortBy { get; init; } + + /// + /// 是否倒序。 + /// + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Validators/CreateDeliveryOrderCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Validators/CreateDeliveryOrderCommandValidator.cs new file mode 100644 index 0000000..765e47f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Validators/CreateDeliveryOrderCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Deliveries.Commands; + +namespace TakeoutSaaS.Application.App.Deliveries.Validators; + +/// +/// 创建配送单命令验证器。 +/// +public sealed class CreateDeliveryOrderCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Validators/SearchDeliveryOrdersQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Validators/SearchDeliveryOrdersQueryValidator.cs new file mode 100644 index 0000000..2119152 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Validators/SearchDeliveryOrdersQueryValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Deliveries.Queries; + +namespace TakeoutSaaS.Application.App.Deliveries.Validators; + +/// +/// 配送单列表查询验证器。 +/// +public sealed class SearchDeliveryOrdersQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public SearchDeliveryOrdersQueryValidator() + { + RuleFor(x => x.Page).GreaterThan(0); + RuleFor(x => x.PageSize).InclusiveBetween(1, 200); + RuleFor(x => x.SortBy).MaximumLength(64); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Validators/UpdateDeliveryOrderCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Validators/UpdateDeliveryOrderCommandValidator.cs new file mode 100644 index 0000000..e2cbc19 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Validators/UpdateDeliveryOrderCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Deliveries.Commands; + +namespace TakeoutSaaS.Application.App.Deliveries.Validators; + +/// +/// 更新配送单命令验证器。 +/// +public sealed class UpdateDeliveryOrderCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs new file mode 100644 index 0000000..62284b1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs @@ -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; + +/// +/// 业务应用层服务注册。 +/// +public static class AppApplicationServiceCollectionExtensions +{ + /// + /// 注册业务应用层(MediatR 处理器等)。 + /// + /// 服务集合。 + /// 服务集合。 + public static IServiceCollection AddAppApplication(this IServiceCollection services) + { + services.AddMediatR(Assembly.GetExecutingAssembly()); + services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCommand.cs new file mode 100644 index 0000000..ba98a8c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCommand.cs @@ -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; + +/// +/// 创建商户命令。 +/// +public sealed class CreateMerchantCommand : IRequest +{ + /// + /// 品牌名称。 + /// + [Required, MaxLength(128)] + public string BrandName { get; init; } = string.Empty; + + /// + /// 品牌简称。 + /// + [MaxLength(64)] + public string? BrandAlias { get; init; } + + /// + /// 品牌 Logo。 + /// + [MaxLength(256)] + public string? LogoUrl { get; init; } + + /// + /// 品类。 + /// + [MaxLength(64)] + public string? Category { get; init; } + + /// + /// 联系电话。 + /// + [Required, MaxLength(32)] + public string ContactPhone { get; init; } = string.Empty; + + /// + /// 联系邮箱。 + /// + [MaxLength(128)] + public string? ContactEmail { get; init; } + + /// + /// 状态,可用于直接设为审核通过等场景。 + /// + public MerchantStatus Status { get; init; } = MerchantStatus.Pending; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCommand.cs new file mode 100644 index 0000000..415665c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 删除商户命令。 +/// +public sealed class DeleteMerchantCommand : IRequest +{ + /// + /// 商户 ID。 + /// + public long MerchantId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantCommand.cs new file mode 100644 index 0000000..feb73f9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantCommand.cs @@ -0,0 +1,51 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 更新商户命令。 +/// +public sealed record UpdateMerchantCommand : IRequest +{ + /// + /// 商户 ID。 + /// + public long MerchantId { get; init; } + + /// + /// 品牌名称。 + /// + public string BrandName { get; init; } = string.Empty; + + /// + /// 品牌简称。 + /// + public string? BrandAlias { get; init; } + + /// + /// Logo 地址。 + /// + public string? LogoUrl { get; init; } + + /// + /// 品类。 + /// + public string? Category { get; init; } + + /// + /// 联系电话。 + /// + public string ContactPhone { get; init; } = string.Empty; + + /// + /// 联系邮箱。 + /// + public string? ContactEmail { get; init; } + + /// + /// 入驻状态。 + /// + public MerchantStatus Status { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDto.cs new file mode 100644 index 0000000..6124ec7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDto.cs @@ -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; + +/// +/// 商户 DTO。 +/// +public sealed class MerchantDto +{ + /// + /// 商户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 品牌名称。 + /// + public string BrandName { get; init; } = string.Empty; + + /// + /// 品牌简称。 + /// + public string? BrandAlias { get; init; } + + /// + /// 品牌 Logo。 + /// + public string? LogoUrl { get; init; } + + /// + /// 品类。 + /// + public string? Category { get; init; } + + /// + /// 联系电话。 + /// + public string ContactPhone { get; init; } = string.Empty; + + /// + /// 联系邮箱。 + /// + public string? ContactEmail { get; init; } + + /// + /// 入驻状态。 + /// + public MerchantStatus Status { get; init; } + + /// + /// 入驻时间。 + /// + public DateTime? JoinedAt { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs new file mode 100644 index 0000000..70c0982 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs @@ -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; + +/// +/// 创建商户命令处理器。 +/// +public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRepository, ILogger logger) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreateMerchantCommand request, CancellationToken cancellationToken) + { + 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 + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCommandHandler.cs new file mode 100644 index 0000000..8f69ff0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCommandHandler.cs @@ -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; + +/// +/// 删除商户命令处理器。 +/// +public sealed class DeleteMerchantCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeleteMerchantCommand request, CancellationToken cancellationToken) + { + // 1. 校验存在性 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); + if (existing == null) + { + return false; + } + + // 2. 删除 + await _merchantRepository.DeleteMerchantAsync(request.MerchantId, tenantId, cancellationToken); + await _merchantRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除商户 {MerchantId}", request.MerchantId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantByIdQueryHandler.cs new file mode 100644 index 0000000..3c2313f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantByIdQueryHandler.cs @@ -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; + +/// +/// 获取商户详情查询处理器。 +/// +public sealed class GetMerchantByIdQueryHandler(IMerchantRepository merchantRepository, ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task Handle(GetMerchantByIdQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); + 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/SearchMerchantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/SearchMerchantsQueryHandler.cs new file mode 100644 index 0000000..8d46242 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/SearchMerchantsQueryHandler.cs @@ -0,0 +1,69 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 商户列表查询处理器。 +/// +public sealed class SearchMerchantsQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(SearchMerchantsQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var merchants = await _merchantRepository.SearchAsync(tenantId, request.Status, cancellationToken); + + var sorted = ApplySorting(merchants, request.SortBy, request.SortDescending); + var paged = sorted + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .ToList(); + + var items = paged.Select(merchant => 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 + }).ToList(); + + return new PagedResult(items, request.Page, request.PageSize, merchants.Count); + } + + private static IOrderedEnumerable ApplySorting( + IReadOnlyCollection merchants, + string? sortBy, + bool sortDescending) + { + return sortBy?.ToLowerInvariant() switch + { + "brandname" => sortDescending + ? merchants.OrderByDescending(x => x.BrandName) + : merchants.OrderBy(x => x.BrandName), + "status" => sortDescending + ? merchants.OrderByDescending(x => x.Status) + : merchants.OrderBy(x => x.Status), + _ => sortDescending + ? merchants.OrderByDescending(x => x.CreatedAt) + : merchants.OrderBy(x => x.CreatedAt) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs new file mode 100644 index 0000000..38753c9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs @@ -0,0 +1,66 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 更新商户命令处理器。 +/// +public sealed class UpdateMerchantCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IMerchantRepository _merchantRepository = merchantRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdateMerchantCommand request, CancellationToken cancellationToken) + { + // 1. 读取现有商户 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); + if (existing == null) + { + return null; + } + + // 2. 更新字段 + existing.BrandName = request.BrandName.Trim(); + existing.BrandAlias = request.BrandAlias?.Trim(); + existing.LogoUrl = request.LogoUrl?.Trim(); + existing.Category = request.Category?.Trim(); + existing.ContactPhone = request.ContactPhone.Trim(); + existing.ContactEmail = request.ContactEmail?.Trim(); + existing.Status = request.Status; + + // 3. 持久化 + await _merchantRepository.UpdateMerchantAsync(existing, cancellationToken); + await _merchantRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("更新商户 {MerchantId} - {BrandName}", existing.Id, existing.BrandName); + + // 4. 返回 DTO + return MapToDto(existing); + } + + private static MerchantDto MapToDto(Domain.Merchants.Entities.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 + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantByIdQuery.cs new file mode 100644 index 0000000..fce904d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantByIdQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 按 ID 获取商户。 +/// +public sealed class GetMerchantByIdQuery : IRequest +{ + /// + /// 商户 ID。 + /// + public long MerchantId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/SearchMerchantsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/SearchMerchantsQuery.cs new file mode 100644 index 0000000..b3ca969 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/SearchMerchantsQuery.cs @@ -0,0 +1,37 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 搜索商户列表。 +/// +public sealed class SearchMerchantsQuery : IRequest> +{ + /// + /// 按状态过滤。 + /// + public MerchantStatus? Status { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段(brandName/status/createdAt)。 + /// + public string? SortBy { get; init; } + + /// + /// 是否倒序。 + /// + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/CreateMerchantCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/CreateMerchantCommandValidator.cs new file mode 100644 index 0000000..bd930d7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/CreateMerchantCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Merchants.Commands; + +namespace TakeoutSaaS.Application.App.Merchants.Validators; + +/// +/// 创建商户命令验证器。 +/// +public sealed class CreateMerchantCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateMerchantCommandValidator() + { + RuleFor(x => x.BrandName).NotEmpty().MaximumLength(128); + RuleFor(x => x.BrandAlias).MaximumLength(64); + RuleFor(x => x.LogoUrl).MaximumLength(256); + RuleFor(x => x.Category).MaximumLength(64); + RuleFor(x => x.ContactPhone).NotEmpty().MaximumLength(32); + RuleFor(x => x.ContactEmail).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ContactEmail)); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/SearchMerchantsQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/SearchMerchantsQueryValidator.cs new file mode 100644 index 0000000..e14707f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/SearchMerchantsQueryValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Merchants.Queries; + +namespace TakeoutSaaS.Application.App.Merchants.Validators; + +/// +/// 商户列表查询验证器。 +/// +public sealed class SearchMerchantsQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public SearchMerchantsQueryValidator() + { + RuleFor(x => x.Page).GreaterThan(0); + RuleFor(x => x.PageSize).InclusiveBetween(1, 200); + RuleFor(x => x.SortBy).MaximumLength(64); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/UpdateMerchantCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/UpdateMerchantCommandValidator.cs new file mode 100644 index 0000000..6781a0b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Validators/UpdateMerchantCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Merchants.Commands; + +namespace TakeoutSaaS.Application.App.Merchants.Validators; + +/// +/// 更新商户命令验证器。 +/// +public sealed class UpdateMerchantCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateMerchantCommandValidator() + { + RuleFor(x => x.MerchantId).GreaterThan(0); + RuleFor(x => x.BrandName).NotEmpty().MaximumLength(128); + RuleFor(x => x.BrandAlias).MaximumLength(64); + RuleFor(x => x.LogoUrl).MaximumLength(256); + RuleFor(x => x.Category).MaximumLength(64); + RuleFor(x => x.ContactPhone).NotEmpty().MaximumLength(32); + RuleFor(x => x.ContactEmail).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ContactEmail)); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Commands/CreateOrderCommand.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Commands/CreateOrderCommand.cs new file mode 100644 index 0000000..824de33 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Commands/CreateOrderCommand.cs @@ -0,0 +1,117 @@ +using MediatR; +using TakeoutSaaS.Application.App.Orders.Dto; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Payments.Enums; + +namespace TakeoutSaaS.Application.App.Orders.Commands; + +/// +/// 创建订单命令。 +/// +public sealed class CreateOrderCommand : IRequest +{ + /// + /// 订单号。 + /// + public string OrderNo { get; set; } = string.Empty; + + /// + /// 门店 ID。 + /// + public long StoreId { get; set; } + + /// + /// 渠道。 + /// + public OrderChannel Channel { get; set; } = OrderChannel.MiniProgram; + + /// + /// 履约方式。 + /// + public DeliveryType DeliveryType { get; set; } = DeliveryType.DineIn; + + /// + /// 状态。 + /// + public OrderStatus Status { get; set; } = OrderStatus.PendingPayment; + + /// + /// 支付状态。 + /// + public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Unpaid; + + /// + /// 顾客姓名。 + /// + public string? CustomerName { get; set; } + + /// + /// 顾客手机号。 + /// + public string? CustomerPhone { get; set; } + + /// + /// 桌号。 + /// + public string? TableNo { get; set; } + + /// + /// 排队号。 + /// + public string? QueueNumber { get; set; } + + /// + /// 预约 ID。 + /// + public long? ReservationId { get; set; } + + /// + /// 商品金额。 + /// + public decimal ItemsAmount { get; set; } + + /// + /// 优惠金额。 + /// + public decimal DiscountAmount { get; set; } + + /// + /// 应付金额。 + /// + public decimal PayableAmount { get; set; } + + /// + /// 实付金额。 + /// + public decimal PaidAmount { get; set; } + + /// + /// 支付时间。 + /// + public DateTime? PaidAt { get; set; } + + /// + /// 完成时间。 + /// + public DateTime? FinishedAt { get; set; } + + /// + /// 取消时间。 + /// + public DateTime? CancelledAt { get; set; } + + /// + /// 取消原因。 + /// + public string? CancelReason { get; set; } + + /// + /// 备注。 + /// + public string? Remark { get; set; } + + /// + /// 明细。 + /// + public IReadOnlyList Items { get; set; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Commands/DeleteOrderCommand.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Commands/DeleteOrderCommand.cs new file mode 100644 index 0000000..f7632c6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Commands/DeleteOrderCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Orders.Commands; + +/// +/// 删除订单命令。 +/// +public sealed class DeleteOrderCommand : IRequest +{ + /// + /// 订单 ID。 + /// + public long OrderId { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Commands/OrderItemRequest.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Commands/OrderItemRequest.cs new file mode 100644 index 0000000..544cd85 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Commands/OrderItemRequest.cs @@ -0,0 +1,52 @@ +namespace TakeoutSaaS.Application.App.Orders.Commands; + +/// +/// 订单明细请求。 +/// +public sealed class OrderItemRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; set; } + + /// + /// 商品名称。 + /// + public string ProductName { get; set; } = string.Empty; + + /// + /// SKU 描述。 + /// + public string? SkuName { get; set; } + + /// + /// 单位。 + /// + public string? Unit { get; set; } + + /// + /// 数量。 + /// + public int Quantity { get; set; } + + /// + /// 单价。 + /// + public decimal UnitPrice { get; set; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; set; } + + /// + /// 小计。 + /// + public decimal SubTotal { get; set; } + + /// + /// 属性 JSON。 + /// + public string? AttributesJson { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Commands/UpdateOrderCommand.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Commands/UpdateOrderCommand.cs new file mode 100644 index 0000000..12de4b7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Commands/UpdateOrderCommand.cs @@ -0,0 +1,117 @@ +using MediatR; +using TakeoutSaaS.Application.App.Orders.Dto; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Payments.Enums; + +namespace TakeoutSaaS.Application.App.Orders.Commands; + +/// +/// 更新订单命令。 +/// +public sealed record UpdateOrderCommand : IRequest +{ + /// + /// 订单 ID。 + /// + public long OrderId { get; init; } + + /// + /// 订单号。 + /// + public string OrderNo { get; init; } = string.Empty; + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 渠道。 + /// + public OrderChannel Channel { get; init; } = OrderChannel.MiniProgram; + + /// + /// 履约方式。 + /// + public DeliveryType DeliveryType { get; init; } = DeliveryType.DineIn; + + /// + /// 状态。 + /// + public OrderStatus Status { get; init; } = OrderStatus.PendingPayment; + + /// + /// 支付状态。 + /// + public PaymentStatus PaymentStatus { get; init; } = PaymentStatus.Unpaid; + + /// + /// 顾客姓名。 + /// + public string? CustomerName { get; init; } + + /// + /// 顾客手机号。 + /// + public string? CustomerPhone { get; init; } + + /// + /// 桌号。 + /// + public string? TableNo { get; init; } + + /// + /// 排队号。 + /// + public string? QueueNumber { get; init; } + + /// + /// 预约 ID。 + /// + public long? ReservationId { get; init; } + + /// + /// 商品金额。 + /// + public decimal ItemsAmount { get; init; } + + /// + /// 优惠金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 应付金额。 + /// + public decimal PayableAmount { get; init; } + + /// + /// 实付金额。 + /// + public decimal PaidAmount { get; init; } + + /// + /// 支付时间。 + /// + public DateTime? PaidAt { get; init; } + + /// + /// 完成时间。 + /// + public DateTime? FinishedAt { get; init; } + + /// + /// 取消时间。 + /// + public DateTime? CancelledAt { get; init; } + + /// + /// 取消原因。 + /// + public string? CancelReason { get; init; } + + /// + /// 备注。 + /// + public string? Remark { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderDto.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderDto.cs new file mode 100644 index 0000000..6add72b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderDto.cs @@ -0,0 +1,146 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Orders.Dto; + +/// +/// 订单 DTO。 +/// +public sealed class OrderDto +{ + /// + /// 订单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 订单号。 + /// + public string OrderNo { get; init; } = string.Empty; + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 渠道。 + /// + public OrderChannel Channel { get; init; } + + /// + /// 履约方式。 + /// + public DeliveryType DeliveryType { get; init; } + + /// + /// 状态。 + /// + public OrderStatus Status { get; init; } + + /// + /// 支付状态。 + /// + public PaymentStatus PaymentStatus { get; init; } + + /// + /// 顾客姓名。 + /// + public string? CustomerName { get; init; } + + /// + /// 顾客手机号。 + /// + public string? CustomerPhone { get; init; } + + /// + /// 桌号。 + /// + public string? TableNo { get; init; } + + /// + /// 排队号。 + /// + public string? QueueNumber { get; init; } + + /// + /// 预约 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? ReservationId { get; init; } + + /// + /// 商品金额。 + /// + public decimal ItemsAmount { get; init; } + + /// + /// 优惠金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 应付金额。 + /// + public decimal PayableAmount { get; init; } + + /// + /// 实付金额。 + /// + public decimal PaidAmount { get; init; } + + /// + /// 支付时间。 + /// + public DateTime? PaidAt { get; init; } + + /// + /// 完成时间。 + /// + public DateTime? FinishedAt { get; init; } + + /// + /// 取消时间。 + /// + public DateTime? CancelledAt { get; init; } + + /// + /// 取消原因。 + /// + public string? CancelReason { get; init; } + + /// + /// 备注。 + /// + public string? Remark { get; init; } + + /// + /// 明细。 + /// + public IReadOnlyList Items { get; init; } = Array.Empty(); + + /// + /// 状态流转。 + /// + public IReadOnlyList StatusHistory { get; init; } = Array.Empty(); + + /// + /// 退款申请。 + /// + public IReadOnlyList Refunds { get; init; } = Array.Empty(); + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderItemDto.cs new file mode 100644 index 0000000..6baa720 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderItemDto.cs @@ -0,0 +1,68 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Orders.Dto; + +/// +/// 订单明细 DTO。 +/// +public sealed class OrderItemDto +{ + /// + /// 明细 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 订单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long OrderId { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 商品名称。 + /// + public string ProductName { get; init; } = string.Empty; + + /// + /// SKU 描述。 + /// + public string? SkuName { get; init; } + + /// + /// 单位。 + /// + public string? Unit { get; init; } + + /// + /// 数量。 + /// + public int Quantity { get; init; } + + /// + /// 单价。 + /// + public decimal UnitPrice { get; init; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; init; } + + /// + /// 小计。 + /// + public decimal SubTotal { get; init; } + + /// + /// 属性 JSON。 + /// + public string? AttributesJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderStatusHistoryDto.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderStatusHistoryDto.cs new file mode 100644 index 0000000..e62e45e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Dto/OrderStatusHistoryDto.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Orders.Dto; + +/// +/// 订单状态流转 DTO。 +/// +public sealed class OrderStatusHistoryDto +{ + /// + /// 记录 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 订单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long OrderId { get; init; } + + /// + /// 状态。 + /// + public OrderStatus Status { get; init; } + + /// + /// 操作人。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? OperatorId { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } + + /// + /// 时间。 + /// + public DateTime OccurredAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Dto/RefundRequestDto.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Dto/RefundRequestDto.cs new file mode 100644 index 0000000..d389554 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Dto/RefundRequestDto.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Orders.Dto; + +/// +/// 退款申请 DTO。 +/// +public sealed class RefundRequestDto +{ + /// + /// 退款 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 订单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long OrderId { get; init; } + + /// + /// 退款单号。 + /// + public string RefundNo { get; init; } = string.Empty; + + /// + /// 金额。 + /// + public decimal Amount { get; init; } + + /// + /// 原因。 + /// + public string Reason { get; init; } = string.Empty; + + /// + /// 状态。 + /// + public RefundStatus Status { get; init; } + + /// + /// 申请时间。 + /// + public DateTime RequestedAt { get; init; } + + /// + /// 处理时间。 + /// + public DateTime? ProcessedAt { get; init; } + + /// + /// 审核备注。 + /// + public string? ReviewNotes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs new file mode 100644 index 0000000..a7b1289 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs @@ -0,0 +1,157 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Orders.Commands; +using TakeoutSaaS.Application.App.Orders.Dto; +using TakeoutSaaS.Domain.Orders.Entities; +using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Application.App.Orders.Handlers; + +/// +/// 创建订单命令处理器。 +/// +public sealed class CreateOrderCommandHandler( + IOrderRepository orderRepository, + IIdGenerator idGenerator, + ILogger logger) + : IRequestHandler +{ + private readonly IOrderRepository _orderRepository = orderRepository; + private readonly IIdGenerator _idGenerator = idGenerator; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreateOrderCommand request, CancellationToken cancellationToken) + { + // 1. 构建订单 + var order = new Order + { + Id = _idGenerator.NextId(), + OrderNo = request.OrderNo.Trim(), + StoreId = request.StoreId, + Channel = request.Channel, + DeliveryType = request.DeliveryType, + Status = request.Status, + PaymentStatus = request.PaymentStatus, + CustomerName = request.CustomerName?.Trim(), + CustomerPhone = request.CustomerPhone?.Trim(), + TableNo = request.TableNo?.Trim(), + QueueNumber = request.QueueNumber?.Trim(), + ReservationId = request.ReservationId, + ItemsAmount = request.ItemsAmount, + DiscountAmount = request.DiscountAmount, + PayableAmount = request.PayableAmount, + PaidAmount = request.PaidAmount, + PaidAt = request.PaidAt, + FinishedAt = request.FinishedAt, + CancelledAt = request.CancelledAt, + CancelReason = request.CancelReason?.Trim(), + Remark = request.Remark?.Trim() + }; + + // 2. 构建明细 + var items = request.Items.Select(item => new OrderItem + { + OrderId = order.Id, + ProductId = item.ProductId, + ProductName = item.ProductName.Trim(), + SkuName = item.SkuName?.Trim(), + Unit = item.Unit?.Trim(), + Quantity = item.Quantity, + UnitPrice = item.UnitPrice, + DiscountAmount = item.DiscountAmount, + SubTotal = item.SubTotal, + AttributesJson = item.AttributesJson?.Trim() + }).ToList(); + + // 3. 补充金额字段 + if (items.Count > 0) + { + var itemsAmount = items.Sum(x => x.SubTotal); + order.ItemsAmount = itemsAmount; + if (order.PayableAmount <= 0) + { + order.PayableAmount = itemsAmount - order.DiscountAmount; + } + } + + // 4. 持久化 + await _orderRepository.AddOrderAsync(order, cancellationToken); + if (items.Count > 0) + { + await _orderRepository.AddItemsAsync(items, cancellationToken); + } + await _orderRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("创建订单 {OrderNo} ({OrderId})", order.OrderNo, order.Id); + + // 5. 返回 DTO + return MapToDto(order, items, [], []); + } + + private static OrderDto MapToDto( + Order order, + IReadOnlyList items, + IReadOnlyList histories, + IReadOnlyList refunds) => new() + { + Id = order.Id, + TenantId = order.TenantId, + OrderNo = order.OrderNo, + StoreId = order.StoreId, + Channel = order.Channel, + DeliveryType = order.DeliveryType, + Status = order.Status, + PaymentStatus = order.PaymentStatus, + CustomerName = order.CustomerName, + CustomerPhone = order.CustomerPhone, + TableNo = order.TableNo, + QueueNumber = order.QueueNumber, + ReservationId = order.ReservationId, + ItemsAmount = order.ItemsAmount, + DiscountAmount = order.DiscountAmount, + PayableAmount = order.PayableAmount, + PaidAmount = order.PaidAmount, + PaidAt = order.PaidAt, + FinishedAt = order.FinishedAt, + CancelledAt = order.CancelledAt, + CancelReason = order.CancelReason, + Remark = order.Remark, + Items = items.Select(x => new OrderItemDto + { + Id = x.Id, + OrderId = x.OrderId, + ProductId = x.ProductId, + ProductName = x.ProductName, + SkuName = x.SkuName, + Unit = x.Unit, + Quantity = x.Quantity, + UnitPrice = x.UnitPrice, + DiscountAmount = x.DiscountAmount, + SubTotal = x.SubTotal, + AttributesJson = x.AttributesJson + }).ToList(), + StatusHistory = histories.Select(x => new OrderStatusHistoryDto + { + Id = x.Id, + OrderId = x.OrderId, + Status = x.Status, + OperatorId = x.OperatorId, + Notes = x.Notes, + OccurredAt = x.OccurredAt + }).ToList(), + Refunds = refunds.Select(x => new RefundRequestDto + { + Id = x.Id, + OrderId = x.OrderId, + RefundNo = x.RefundNo, + Amount = x.Amount, + Reason = x.Reason, + Status = x.Status, + RequestedAt = x.RequestedAt, + ProcessedAt = x.ProcessedAt, + ReviewNotes = x.ReviewNotes + }).ToList(), + CreatedAt = order.CreatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/DeleteOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/DeleteOrderCommandHandler.cs new file mode 100644 index 0000000..d376e47 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/DeleteOrderCommandHandler.cs @@ -0,0 +1,40 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Orders.Commands; +using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Orders.Handlers; + +/// +/// 删除订单命令处理器。 +/// +public sealed class DeleteOrderCommandHandler( + IOrderRepository orderRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IOrderRepository _orderRepository = orderRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeleteOrderCommand request, CancellationToken cancellationToken) + { + // 1. 校验存在性 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); + if (existing == null) + { + return false; + } + + // 2. 删除 + await _orderRepository.DeleteOrderAsync(request.OrderId, tenantId, cancellationToken); + await _orderRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除订单 {OrderId}", request.OrderId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderByIdQueryHandler.cs new file mode 100644 index 0000000..5d6b791 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderByIdQueryHandler.cs @@ -0,0 +1,103 @@ +using MediatR; +using TakeoutSaaS.Application.App.Orders.Dto; +using TakeoutSaaS.Application.App.Orders.Queries; +using TakeoutSaaS.Domain.Orders.Entities; +using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Orders.Handlers; + +/// +/// 订单详情查询处理器。 +/// +public sealed class GetOrderByIdQueryHandler( + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IOrderRepository _orderRepository = orderRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task Handle(GetOrderByIdQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var order = await _orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); + if (order == null) + { + return null; + } + + var items = await _orderRepository.GetItemsAsync(order.Id, tenantId, cancellationToken); + var histories = await _orderRepository.GetStatusHistoryAsync(order.Id, tenantId, cancellationToken); + var refunds = await _orderRepository.GetRefundsAsync(order.Id, tenantId, cancellationToken); + + return MapToDto(order, items, histories, refunds); + } + + private static OrderDto MapToDto( + Order order, + IReadOnlyList items, + IReadOnlyList histories, + IReadOnlyList refunds) => new() + { + Id = order.Id, + TenantId = order.TenantId, + OrderNo = order.OrderNo, + StoreId = order.StoreId, + Channel = order.Channel, + DeliveryType = order.DeliveryType, + Status = order.Status, + PaymentStatus = order.PaymentStatus, + CustomerName = order.CustomerName, + CustomerPhone = order.CustomerPhone, + TableNo = order.TableNo, + QueueNumber = order.QueueNumber, + ReservationId = order.ReservationId, + ItemsAmount = order.ItemsAmount, + DiscountAmount = order.DiscountAmount, + PayableAmount = order.PayableAmount, + PaidAmount = order.PaidAmount, + PaidAt = order.PaidAt, + FinishedAt = order.FinishedAt, + CancelledAt = order.CancelledAt, + CancelReason = order.CancelReason, + Remark = order.Remark, + Items = items.Select(x => new OrderItemDto + { + Id = x.Id, + OrderId = x.OrderId, + ProductId = x.ProductId, + ProductName = x.ProductName, + SkuName = x.SkuName, + Unit = x.Unit, + Quantity = x.Quantity, + UnitPrice = x.UnitPrice, + DiscountAmount = x.DiscountAmount, + SubTotal = x.SubTotal, + AttributesJson = x.AttributesJson + }).ToList(), + StatusHistory = histories.Select(x => new OrderStatusHistoryDto + { + Id = x.Id, + OrderId = x.OrderId, + Status = x.Status, + OperatorId = x.OperatorId, + Notes = x.Notes, + OccurredAt = x.OccurredAt + }).ToList(), + Refunds = refunds.Select(x => new RefundRequestDto + { + Id = x.Id, + OrderId = x.OrderId, + RefundNo = x.RefundNo, + Amount = x.Amount, + Reason = x.Reason, + Status = x.Status, + RequestedAt = x.RequestedAt, + ProcessedAt = x.ProcessedAt, + ReviewNotes = x.ReviewNotes + }).ToList(), + CreatedAt = order.CreatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrdersQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrdersQueryHandler.cs new file mode 100644 index 0000000..867332b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrdersQueryHandler.cs @@ -0,0 +1,89 @@ +using MediatR; +using TakeoutSaaS.Application.App.Orders.Dto; +using TakeoutSaaS.Application.App.Orders.Queries; +using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Orders.Handlers; + +/// +/// 订单列表查询处理器。 +/// +public sealed class SearchOrdersQueryHandler( + IOrderRepository orderRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IOrderRepository _orderRepository = orderRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(SearchOrdersQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var orders = await _orderRepository.SearchAsync(tenantId, request.Status, request.PaymentStatus, cancellationToken); + + if (request.StoreId.HasValue) + { + orders = orders.Where(x => x.StoreId == request.StoreId.Value).ToList(); + } + + if (!string.IsNullOrWhiteSpace(request.OrderNo)) + { + var orderNo = request.OrderNo.Trim(); + orders = orders + .Where(x => x.OrderNo.Contains(orderNo, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + 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 OrderDto + { + Id = order.Id, + TenantId = order.TenantId, + OrderNo = order.OrderNo, + StoreId = order.StoreId, + Channel = order.Channel, + DeliveryType = order.DeliveryType, + Status = order.Status, + PaymentStatus = order.PaymentStatus, + CustomerName = order.CustomerName, + CustomerPhone = order.CustomerPhone, + TableNo = order.TableNo, + QueueNumber = order.QueueNumber, + ReservationId = order.ReservationId, + ItemsAmount = order.ItemsAmount, + DiscountAmount = order.DiscountAmount, + PayableAmount = order.PayableAmount, + PaidAmount = order.PaidAmount, + PaidAt = order.PaidAt, + FinishedAt = order.FinishedAt, + CancelledAt = order.CancelledAt, + CancelReason = order.CancelReason, + Remark = order.Remark, + CreatedAt = order.CreatedAt + }).ToList(); + + return new PagedResult(items, request.Page, request.PageSize, orders.Count); + } + + private static IOrderedEnumerable ApplySorting( + IReadOnlyCollection orders, + string? sortBy, + bool sortDescending) + { + return sortBy?.ToLowerInvariant() switch + { + "paidat" => sortDescending ? orders.OrderByDescending(x => x.PaidAt) : orders.OrderBy(x => x.PaidAt), + "status" => sortDescending ? orders.OrderByDescending(x => x.Status) : orders.OrderBy(x => x.Status), + "payableamount" => sortDescending ? orders.OrderByDescending(x => x.PayableAmount) : orders.OrderBy(x => x.PayableAmount), + _ => sortDescending ? orders.OrderByDescending(x => x.CreatedAt) : orders.OrderBy(x => x.CreatedAt) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/UpdateOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/UpdateOrderCommandHandler.cs new file mode 100644 index 0000000..46df4ed --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/UpdateOrderCommandHandler.cs @@ -0,0 +1,135 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Orders.Commands; +using TakeoutSaaS.Application.App.Orders.Dto; +using TakeoutSaaS.Domain.Orders.Entities; +using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Orders.Handlers; + +/// +/// 更新订单命令处理器。 +/// +public sealed class UpdateOrderCommandHandler( + IOrderRepository orderRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IOrderRepository _orderRepository = orderRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdateOrderCommand request, CancellationToken cancellationToken) + { + // 1. 读取订单 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); + if (existing == null) + { + return null; + } + + // 2. 更新字段 + existing.OrderNo = request.OrderNo.Trim(); + existing.StoreId = request.StoreId; + existing.Channel = request.Channel; + existing.DeliveryType = request.DeliveryType; + existing.Status = request.Status; + existing.PaymentStatus = request.PaymentStatus; + existing.CustomerName = request.CustomerName?.Trim(); + existing.CustomerPhone = request.CustomerPhone?.Trim(); + existing.TableNo = request.TableNo?.Trim(); + existing.QueueNumber = request.QueueNumber?.Trim(); + existing.ReservationId = request.ReservationId; + existing.ItemsAmount = request.ItemsAmount; + existing.DiscountAmount = request.DiscountAmount; + existing.PayableAmount = request.PayableAmount; + existing.PaidAmount = request.PaidAmount; + existing.PaidAt = request.PaidAt; + existing.FinishedAt = request.FinishedAt; + existing.CancelledAt = request.CancelledAt; + existing.CancelReason = request.CancelReason?.Trim(); + existing.Remark = request.Remark?.Trim(); + + // 3. 持久化 + await _orderRepository.UpdateOrderAsync(existing, cancellationToken); + await _orderRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("更新订单 {OrderNo} ({OrderId})", existing.OrderNo, existing.Id); + + // 4. 读取关联数据并返回 + var items = await _orderRepository.GetItemsAsync(existing.Id, tenantId, cancellationToken); + var histories = await _orderRepository.GetStatusHistoryAsync(existing.Id, tenantId, cancellationToken); + var refunds = await _orderRepository.GetRefundsAsync(existing.Id, tenantId, cancellationToken); + + return MapToDto(existing, items, histories, refunds); + } + + private static OrderDto MapToDto( + Order order, + IReadOnlyList items, + IReadOnlyList histories, + IReadOnlyList refunds) => new() + { + Id = order.Id, + TenantId = order.TenantId, + OrderNo = order.OrderNo, + StoreId = order.StoreId, + Channel = order.Channel, + DeliveryType = order.DeliveryType, + Status = order.Status, + PaymentStatus = order.PaymentStatus, + CustomerName = order.CustomerName, + CustomerPhone = order.CustomerPhone, + TableNo = order.TableNo, + QueueNumber = order.QueueNumber, + ReservationId = order.ReservationId, + ItemsAmount = order.ItemsAmount, + DiscountAmount = order.DiscountAmount, + PayableAmount = order.PayableAmount, + PaidAmount = order.PaidAmount, + PaidAt = order.PaidAt, + FinishedAt = order.FinishedAt, + CancelledAt = order.CancelledAt, + CancelReason = order.CancelReason, + Remark = order.Remark, + Items = items.Select(x => new OrderItemDto + { + Id = x.Id, + OrderId = x.OrderId, + ProductId = x.ProductId, + ProductName = x.ProductName, + SkuName = x.SkuName, + Unit = x.Unit, + Quantity = x.Quantity, + UnitPrice = x.UnitPrice, + DiscountAmount = x.DiscountAmount, + SubTotal = x.SubTotal, + AttributesJson = x.AttributesJson + }).ToList(), + StatusHistory = histories.Select(x => new OrderStatusHistoryDto + { + Id = x.Id, + OrderId = x.OrderId, + Status = x.Status, + OperatorId = x.OperatorId, + Notes = x.Notes, + OccurredAt = x.OccurredAt + }).ToList(), + Refunds = refunds.Select(x => new RefundRequestDto + { + Id = x.Id, + OrderId = x.OrderId, + RefundNo = x.RefundNo, + Amount = x.Amount, + Reason = x.Reason, + Status = x.Status, + RequestedAt = x.RequestedAt, + ProcessedAt = x.ProcessedAt, + ReviewNotes = x.ReviewNotes + }).ToList(), + CreatedAt = order.CreatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Queries/GetOrderByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Queries/GetOrderByIdQuery.cs new file mode 100644 index 0000000..1446c27 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Queries/GetOrderByIdQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Orders.Dto; + +namespace TakeoutSaaS.Application.App.Orders.Queries; + +/// +/// 获取订单详情查询。 +/// +public sealed class GetOrderByIdQuery : IRequest +{ + /// + /// 订单 ID。 + /// + public long OrderId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Queries/SearchOrdersQuery.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Queries/SearchOrdersQuery.cs new file mode 100644 index 0000000..74c6753 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Queries/SearchOrdersQuery.cs @@ -0,0 +1,53 @@ +using MediatR; +using TakeoutSaaS.Application.App.Orders.Dto; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Orders.Queries; + +/// +/// 订单列表查询。 +/// +public sealed class SearchOrdersQuery : IRequest> +{ + /// + /// 门店 ID(可选)。 + /// + public long? StoreId { get; init; } + + /// + /// 订单状态。 + /// + public OrderStatus? Status { get; init; } + + /// + /// 支付状态。 + /// + public PaymentStatus? PaymentStatus { get; init; } + + /// + /// 订单号(模糊或精确,由调用方控制)。 + /// + public string? OrderNo { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段(createdAt/paidAt/status/payableAmount)。 + /// + public string? SortBy { get; init; } + + /// + /// 是否倒序。 + /// + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Validators/CreateOrderCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/CreateOrderCommandValidator.cs new file mode 100644 index 0000000..60f3d0c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/CreateOrderCommandValidator.cs @@ -0,0 +1,79 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Orders.Commands; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Payments.Enums; + +namespace TakeoutSaaS.Application.App.Orders.Validators; + +/// +/// 创建订单命令验证器。 +/// +public sealed class CreateOrderCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateOrderCommandValidator() + { + RuleFor(x => x.OrderNo).NotEmpty().MaximumLength(32); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.CustomerPhone).MaximumLength(32); + RuleFor(x => x.CustomerName).MaximumLength(64); + RuleFor(x => x.TableNo).MaximumLength(32); + RuleFor(x => x.QueueNumber).MaximumLength(32); + RuleFor(x => x.CancelReason).MaximumLength(256); + RuleFor(x => x.Remark).MaximumLength(512); + RuleFor(x => x.ItemsAmount).GreaterThanOrEqualTo(0); + RuleFor(x => x.DiscountAmount).GreaterThanOrEqualTo(0); + RuleFor(x => x.PayableAmount).GreaterThanOrEqualTo(0); + RuleFor(x => x.PaidAmount).GreaterThanOrEqualTo(0); + RuleFor(x => x.PayableAmount) + .Must((cmd, payable) => payable == cmd.ItemsAmount - cmd.DiscountAmount) + .WithMessage("应付金额必须等于商品金额减去优惠金额"); + RuleFor(x => x.PaidAmount) + .LessThanOrEqualTo(x => x.PayableAmount) + .WithMessage("实付金额不得超过应付金额"); + + RuleFor(x => x.PaymentStatus) + .Must(status => status is PaymentStatus.Unpaid or PaymentStatus.Paying or PaymentStatus.Paid or PaymentStatus.Failed or PaymentStatus.Refunded) + .WithMessage("支付状态不合法"); + When(x => x.PaymentStatus == PaymentStatus.Paid, () => + { + RuleFor(x => x.PaidAt).NotNull().WithMessage("支付成功必须包含支付时间"); + RuleFor(x => x.PaidAmount).GreaterThan(0).WithMessage("支付成功时实付金额必须大于 0"); + }); + When(x => x.PaymentStatus != PaymentStatus.Paid, () => + { + RuleFor(x => x.PaidAt).Must(paidAt => paidAt == null).WithMessage("非支付成功状态不应包含支付时间"); + }); + When(x => x.PaymentStatus == PaymentStatus.Refunded, () => + { + RuleFor(x => x.PaidAmount).GreaterThanOrEqualTo(0).WithMessage("退款状态下实付金额需合法"); + }); + When(x => x.Status == OrderStatus.Cancelled, () => + { + RuleFor(x => x.CancelReason).NotEmpty().WithMessage("取消订单必须提供取消原因"); + }); + When(x => x.Status != OrderStatus.Cancelled, () => + { + RuleFor(x => x.CancelReason).Must(reason => string.IsNullOrWhiteSpace(reason)).WithMessage("非取消状态不应包含取消原因"); + }); + + RuleFor(x => x.Items) + .NotEmpty() + .WithMessage("订单明细不能为空"); + + RuleForEach(x => x.Items).ChildRules(item => + { + item.RuleFor(i => i.ProductId).GreaterThan(0); + item.RuleFor(i => i.ProductName).NotEmpty().MaximumLength(128); + item.RuleFor(i => i.SkuName).MaximumLength(128); + item.RuleFor(i => i.Unit).MaximumLength(16); + item.RuleFor(i => i.Quantity).GreaterThan(0); + item.RuleFor(i => i.UnitPrice).GreaterThanOrEqualTo(0); + item.RuleFor(i => i.DiscountAmount).GreaterThanOrEqualTo(0); + item.RuleFor(i => i.SubTotal).GreaterThanOrEqualTo(0); + item.RuleFor(i => i.AttributesJson).MaximumLength(4000); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Validators/SearchOrdersQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/SearchOrdersQueryValidator.cs new file mode 100644 index 0000000..b5dac41 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/SearchOrdersQueryValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Orders.Queries; + +namespace TakeoutSaaS.Application.App.Orders.Validators; + +/// +/// 订单列表查询验证器。 +/// +public sealed class SearchOrdersQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public SearchOrdersQueryValidator() + { + RuleFor(x => x.Page).GreaterThan(0); + RuleFor(x => x.PageSize).InclusiveBetween(1, 200); + RuleFor(x => x.SortBy).MaximumLength(64); + RuleFor(x => x.OrderNo).MaximumLength(32); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Validators/UpdateOrderCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/UpdateOrderCommandValidator.cs new file mode 100644 index 0000000..745f033 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Validators/UpdateOrderCommandValidator.cs @@ -0,0 +1,63 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Orders.Commands; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Payments.Enums; + +namespace TakeoutSaaS.Application.App.Orders.Validators; + +/// +/// 更新订单命令验证器。 +/// +public sealed class UpdateOrderCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateOrderCommandValidator() + { + RuleFor(x => x.OrderId).GreaterThan(0); + RuleFor(x => x.OrderNo).NotEmpty().MaximumLength(32); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.CustomerPhone).MaximumLength(32); + RuleFor(x => x.CustomerName).MaximumLength(64); + RuleFor(x => x.TableNo).MaximumLength(32); + RuleFor(x => x.QueueNumber).MaximumLength(32); + RuleFor(x => x.CancelReason).MaximumLength(256); + RuleFor(x => x.Remark).MaximumLength(512); + RuleFor(x => x.ItemsAmount).GreaterThanOrEqualTo(0); + RuleFor(x => x.DiscountAmount).GreaterThanOrEqualTo(0); + RuleFor(x => x.PayableAmount).GreaterThanOrEqualTo(0); + RuleFor(x => x.PaidAmount).GreaterThanOrEqualTo(0); + RuleFor(x => x.PayableAmount) + .Must((cmd, payable) => payable == cmd.ItemsAmount - cmd.DiscountAmount) + .WithMessage("应付金额必须等于商品金额减去优惠金额"); + RuleFor(x => x.PaidAmount) + .LessThanOrEqualTo(x => x.PayableAmount) + .WithMessage("实付金额不得超过应付金额"); + + RuleFor(x => x.PaymentStatus) + .Must(status => status is PaymentStatus.Unpaid or PaymentStatus.Paying or PaymentStatus.Paid or PaymentStatus.Failed or PaymentStatus.Refunded) + .WithMessage("支付状态不合法"); + When(x => x.PaymentStatus == PaymentStatus.Paid, () => + { + RuleFor(x => x.PaidAt).NotNull().WithMessage("支付成功必须包含支付时间"); + RuleFor(x => x.PaidAmount).GreaterThan(0).WithMessage("支付成功时实付金额必须大于 0"); + }); + When(x => x.PaymentStatus != PaymentStatus.Paid, () => + { + RuleFor(x => x.PaidAt).Must(paidAt => paidAt == null).WithMessage("非支付成功状态不应包含支付时间"); + }); + When(x => x.PaymentStatus == PaymentStatus.Refunded, () => + { + RuleFor(x => x.PaidAmount).GreaterThanOrEqualTo(0).WithMessage("退款状态下实付金额需合法"); + }); + When(x => x.Status == OrderStatus.Cancelled, () => + { + RuleFor(x => x.CancelReason).NotEmpty().WithMessage("取消订单必须提供取消原因"); + }); + When(x => x.Status != OrderStatus.Cancelled, () => + { + RuleFor(x => x.CancelReason).Must(reason => string.IsNullOrWhiteSpace(reason)).WithMessage("非取消状态不应包含取消原因"); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Commands/CreatePaymentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Commands/CreatePaymentCommand.cs new file mode 100644 index 0000000..ed3c66c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Commands/CreatePaymentCommand.cs @@ -0,0 +1,56 @@ +using MediatR; +using TakeoutSaaS.Application.App.Payments.Dto; +using TakeoutSaaS.Domain.Payments.Enums; + +namespace TakeoutSaaS.Application.App.Payments.Commands; + +/// +/// 创建支付记录命令。 +/// +public sealed class CreatePaymentCommand : IRequest +{ + /// + /// 订单 ID。 + /// + public long OrderId { get; set; } + + /// + /// 支付方式。 + /// + public PaymentMethod Method { get; set; } = PaymentMethod.Unknown; + + /// + /// 支付状态。 + /// + public PaymentStatus Status { get; set; } = PaymentStatus.Unpaid; + + /// + /// 金额。 + /// + public decimal Amount { get; set; } + + /// + /// 平台交易号。 + /// + public string? TradeNo { get; set; } + + /// + /// 渠道单号。 + /// + public string? ChannelTransactionId { get; set; } + + /// + /// 支付时间。 + /// + public DateTime? PaidAt { get; set; } + + /// + /// 备注。 + /// + public string? Remark { get; set; } + + /// + /// 原始回调。 + /// + public string? Payload { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Commands/DeletePaymentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Commands/DeletePaymentCommand.cs new file mode 100644 index 0000000..5c42b47 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Commands/DeletePaymentCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Payments.Commands; + +/// +/// 删除支付记录命令。 +/// +public sealed class DeletePaymentCommand : IRequest +{ + /// + /// 支付记录 ID。 + /// + public long PaymentId { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Commands/UpdatePaymentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Commands/UpdatePaymentCommand.cs new file mode 100644 index 0000000..6b8e9ed --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Commands/UpdatePaymentCommand.cs @@ -0,0 +1,61 @@ +using MediatR; +using TakeoutSaaS.Application.App.Payments.Dto; +using TakeoutSaaS.Domain.Payments.Enums; + +namespace TakeoutSaaS.Application.App.Payments.Commands; + +/// +/// 更新支付记录命令。 +/// +public sealed record UpdatePaymentCommand : IRequest +{ + /// + /// 支付记录 ID。 + /// + public long PaymentId { get; init; } + + /// + /// 订单 ID。 + /// + public long OrderId { get; init; } + + /// + /// 支付方式。 + /// + public PaymentMethod Method { get; init; } = PaymentMethod.Unknown; + + /// + /// 支付状态。 + /// + public PaymentStatus Status { get; init; } = PaymentStatus.Unpaid; + + /// + /// 金额。 + /// + public decimal Amount { get; init; } + + /// + /// 平台交易号。 + /// + public string? TradeNo { get; init; } + + /// + /// 渠道单号。 + /// + public string? ChannelTransactionId { get; init; } + + /// + /// 支付时间。 + /// + public DateTime? PaidAt { get; init; } + + /// + /// 备注。 + /// + public string? Remark { get; init; } + + /// + /// 原始回调。 + /// + public string? Payload { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Dto/PaymentDto.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Dto/PaymentDto.cs new file mode 100644 index 0000000..d127427 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Dto/PaymentDto.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Payments.Dto; + +/// +/// 支付记录 DTO。 +/// +public sealed class PaymentDto +{ + /// + /// 支付记录 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 订单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long OrderId { get; init; } + + /// + /// 支付方式。 + /// + public PaymentMethod Method { get; init; } + + /// + /// 支付状态。 + /// + public PaymentStatus Status { get; init; } + + /// + /// 金额。 + /// + public decimal Amount { get; init; } + + /// + /// 平台交易号。 + /// + public string? TradeNo { get; init; } + + /// + /// 渠道单号。 + /// + public string? ChannelTransactionId { get; init; } + + /// + /// 支付时间。 + /// + public DateTime? PaidAt { get; init; } + + /// + /// 备注。 + /// + public string? Remark { get; init; } + + /// + /// 原始回调。 + /// + public string? Payload { get; init; } + + /// + /// 退款记录。 + /// + public IReadOnlyList Refunds { get; init; } = Array.Empty(); + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Dto/PaymentRefundDto.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Dto/PaymentRefundDto.cs new file mode 100644 index 0000000..5c9cfa8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Dto/PaymentRefundDto.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Payments.Dto; + +/// +/// 退款记录 DTO。 +/// +public sealed class PaymentRefundDto +{ + /// + /// 退款记录 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 支付记录 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long PaymentRecordId { get; init; } + + /// + /// 订单 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long OrderId { get; init; } + + /// + /// 金额。 + /// + public decimal Amount { get; init; } + + /// + /// 渠道退款号。 + /// + public string? ChannelRefundId { get; init; } + + /// + /// 状态。 + /// + public PaymentRefundStatus Status { get; init; } + + /// + /// 原始回调。 + /// + public string? Payload { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/CreatePaymentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/CreatePaymentCommandHandler.cs new file mode 100644 index 0000000..793a2a9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/CreatePaymentCommandHandler.cs @@ -0,0 +1,67 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Payments.Commands; +using TakeoutSaaS.Application.App.Payments.Dto; +using TakeoutSaaS.Domain.Payments.Entities; +using TakeoutSaaS.Domain.Payments.Repositories; + +namespace TakeoutSaaS.Application.App.Payments.Handlers; + +/// +/// 创建支付记录命令处理器。 +/// +public sealed class CreatePaymentCommandHandler(IPaymentRepository paymentRepository, ILogger logger) + : IRequestHandler +{ + private readonly IPaymentRepository _paymentRepository = paymentRepository; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreatePaymentCommand request, CancellationToken cancellationToken) + { + var payment = new PaymentRecord + { + OrderId = request.OrderId, + Method = request.Method, + Status = request.Status, + Amount = request.Amount, + TradeNo = request.TradeNo?.Trim(), + ChannelTransactionId = request.ChannelTransactionId?.Trim(), + PaidAt = request.PaidAt, + Remark = request.Remark?.Trim(), + Payload = request.Payload + }; + + await _paymentRepository.AddPaymentAsync(payment, cancellationToken); + await _paymentRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("创建支付记录 {PaymentId} 对应订单 {OrderId}", payment.Id, payment.OrderId); + + return MapToDto(payment, []); + } + + private static PaymentDto MapToDto(PaymentRecord payment, IReadOnlyList refunds) => new() + { + Id = payment.Id, + TenantId = payment.TenantId, + OrderId = payment.OrderId, + Method = payment.Method, + Status = payment.Status, + Amount = payment.Amount, + TradeNo = payment.TradeNo, + ChannelTransactionId = payment.ChannelTransactionId, + PaidAt = payment.PaidAt, + Remark = payment.Remark, + Payload = payment.Payload, + CreatedAt = payment.CreatedAt, + Refunds = refunds.Select(x => new PaymentRefundDto + { + Id = x.Id, + PaymentRecordId = x.PaymentRecordId, + OrderId = x.OrderId, + Amount = x.Amount, + ChannelRefundId = x.ChannelRefundId, + Status = x.Status, + Payload = x.Payload + }).ToList() + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/DeletePaymentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/DeletePaymentCommandHandler.cs new file mode 100644 index 0000000..07b2ca9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/DeletePaymentCommandHandler.cs @@ -0,0 +1,38 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Payments.Commands; +using TakeoutSaaS.Domain.Payments.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Payments.Handlers; + +/// +/// 删除支付记录命令处理器。 +/// +public sealed class DeletePaymentCommandHandler( + IPaymentRepository paymentRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IPaymentRepository _paymentRepository = paymentRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeletePaymentCommand request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _paymentRepository.FindByIdAsync(request.PaymentId, tenantId, cancellationToken); + if (existing == null) + { + return false; + } + + await _paymentRepository.DeletePaymentAsync(request.PaymentId, tenantId, cancellationToken); + await _paymentRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除支付记录 {PaymentId}", request.PaymentId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/GetPaymentByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/GetPaymentByIdQueryHandler.cs new file mode 100644 index 0000000..8e5db7a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/GetPaymentByIdQueryHandler.cs @@ -0,0 +1,60 @@ +using MediatR; +using TakeoutSaaS.Application.App.Payments.Dto; +using TakeoutSaaS.Application.App.Payments.Queries; +using TakeoutSaaS.Domain.Payments.Entities; +using TakeoutSaaS.Domain.Payments.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Payments.Handlers; + +/// +/// 支付记录详情查询处理器。 +/// +public sealed class GetPaymentByIdQueryHandler( + IPaymentRepository paymentRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IPaymentRepository _paymentRepository = paymentRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task Handle(GetPaymentByIdQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var payment = await _paymentRepository.FindByIdAsync(request.PaymentId, tenantId, cancellationToken); + if (payment == null) + { + return null; + } + + var refunds = await _paymentRepository.GetRefundsAsync(payment.Id, tenantId, cancellationToken); + return MapToDto(payment, refunds); + } + + private static PaymentDto MapToDto(PaymentRecord payment, IReadOnlyList refunds) => new() + { + Id = payment.Id, + TenantId = payment.TenantId, + OrderId = payment.OrderId, + Method = payment.Method, + Status = payment.Status, + Amount = payment.Amount, + TradeNo = payment.TradeNo, + ChannelTransactionId = payment.ChannelTransactionId, + PaidAt = payment.PaidAt, + Remark = payment.Remark, + Payload = payment.Payload, + CreatedAt = payment.CreatedAt, + Refunds = refunds.Select(x => new PaymentRefundDto + { + Id = x.Id, + PaymentRecordId = x.PaymentRecordId, + OrderId = x.OrderId, + Amount = x.Amount, + ChannelRefundId = x.ChannelRefundId, + Status = x.Status, + Payload = x.Payload + }).ToList() + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/SearchPaymentsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/SearchPaymentsQueryHandler.cs new file mode 100644 index 0000000..9d6c4ae --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/SearchPaymentsQueryHandler.cs @@ -0,0 +1,71 @@ +using MediatR; +using TakeoutSaaS.Application.App.Payments.Dto; +using TakeoutSaaS.Application.App.Payments.Queries; +using TakeoutSaaS.Domain.Payments.Entities; +using TakeoutSaaS.Domain.Payments.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Payments.Handlers; + +/// +/// 支付记录列表查询处理器。 +/// +public sealed class SearchPaymentsQueryHandler( + IPaymentRepository paymentRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IPaymentRepository _paymentRepository = paymentRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(SearchPaymentsQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var payments = await _paymentRepository.SearchAsync(tenantId, request.Status, cancellationToken); + + if (request.OrderId.HasValue) + { + payments = payments.Where(x => x.OrderId == request.OrderId.Value).ToList(); + } + + var sorted = ApplySorting(payments, request.SortBy, request.SortDescending); + var paged = sorted + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .ToList(); + + var items = paged.Select(payment => new PaymentDto + { + Id = payment.Id, + TenantId = payment.TenantId, + OrderId = payment.OrderId, + Method = payment.Method, + Status = payment.Status, + Amount = payment.Amount, + TradeNo = payment.TradeNo, + ChannelTransactionId = payment.ChannelTransactionId, + PaidAt = payment.PaidAt, + Remark = payment.Remark, + Payload = payment.Payload, + CreatedAt = payment.CreatedAt + }).ToList(); + + return new PagedResult(items, request.Page, request.PageSize, payments.Count); + } + + private static IOrderedEnumerable ApplySorting( + IReadOnlyCollection payments, + string? sortBy, + bool sortDescending) + { + return sortBy?.ToLowerInvariant() switch + { + "paidat" => sortDescending ? payments.OrderByDescending(x => x.PaidAt) : payments.OrderBy(x => x.PaidAt), + "status" => sortDescending ? payments.OrderByDescending(x => x.Status) : payments.OrderBy(x => x.Status), + "amount" => sortDescending ? payments.OrderByDescending(x => x.Amount) : payments.OrderBy(x => x.Amount), + _ => sortDescending ? payments.OrderByDescending(x => x.CreatedAt) : payments.OrderBy(x => x.CreatedAt) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/UpdatePaymentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/UpdatePaymentCommandHandler.cs new file mode 100644 index 0000000..e0e9fec --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/UpdatePaymentCommandHandler.cs @@ -0,0 +1,77 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Payments.Commands; +using TakeoutSaaS.Application.App.Payments.Dto; +using TakeoutSaaS.Domain.Payments.Entities; +using TakeoutSaaS.Domain.Payments.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Payments.Handlers; + +/// +/// 更新支付记录命令处理器。 +/// +public sealed class UpdatePaymentCommandHandler( + IPaymentRepository paymentRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IPaymentRepository _paymentRepository = paymentRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdatePaymentCommand request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _paymentRepository.FindByIdAsync(request.PaymentId, tenantId, cancellationToken); + if (existing == null) + { + return null; + } + + existing.OrderId = request.OrderId; + existing.Method = request.Method; + existing.Status = request.Status; + existing.Amount = request.Amount; + existing.TradeNo = request.TradeNo?.Trim(); + existing.ChannelTransactionId = request.ChannelTransactionId?.Trim(); + existing.PaidAt = request.PaidAt; + existing.Remark = request.Remark?.Trim(); + existing.Payload = request.Payload; + + await _paymentRepository.UpdatePaymentAsync(existing, cancellationToken); + await _paymentRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("更新支付记录 {PaymentId}", existing.Id); + + var refunds = await _paymentRepository.GetRefundsAsync(existing.Id, tenantId, cancellationToken); + return MapToDto(existing, refunds); + } + + private static PaymentDto MapToDto(PaymentRecord payment, IReadOnlyList refunds) => new() + { + Id = payment.Id, + TenantId = payment.TenantId, + OrderId = payment.OrderId, + Method = payment.Method, + Status = payment.Status, + Amount = payment.Amount, + TradeNo = payment.TradeNo, + ChannelTransactionId = payment.ChannelTransactionId, + PaidAt = payment.PaidAt, + Remark = payment.Remark, + Payload = payment.Payload, + CreatedAt = payment.CreatedAt, + Refunds = refunds.Select(x => new PaymentRefundDto + { + Id = x.Id, + PaymentRecordId = x.PaymentRecordId, + OrderId = x.OrderId, + Amount = x.Amount, + ChannelRefundId = x.ChannelRefundId, + Status = x.Status, + Payload = x.Payload + }).ToList() + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Queries/GetPaymentByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Queries/GetPaymentByIdQuery.cs new file mode 100644 index 0000000..3ca6c8c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Queries/GetPaymentByIdQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Payments.Dto; + +namespace TakeoutSaaS.Application.App.Payments.Queries; + +/// +/// 获取支付记录详情。 +/// +public sealed class GetPaymentByIdQuery : IRequest +{ + /// + /// 支付记录 ID。 + /// + public long PaymentId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Queries/SearchPaymentsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Queries/SearchPaymentsQuery.cs new file mode 100644 index 0000000..2fbf13f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Queries/SearchPaymentsQuery.cs @@ -0,0 +1,42 @@ +using MediatR; +using TakeoutSaaS.Application.App.Payments.Dto; +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Payments.Queries; + +/// +/// 支付记录列表查询。 +/// +public sealed class SearchPaymentsQuery : IRequest> +{ + /// + /// 订单 ID(可选)。 + /// + public long? OrderId { get; init; } + + /// + /// 支付状态。 + /// + public PaymentStatus? Status { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段(createdAt/paidAt/status/amount)。 + /// + public string? SortBy { get; init; } + + /// + /// 是否倒序。 + /// + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Validators/CreatePaymentCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Validators/CreatePaymentCommandValidator.cs new file mode 100644 index 0000000..5041e1d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Validators/CreatePaymentCommandValidator.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Payments.Commands; +using TakeoutSaaS.Domain.Payments.Enums; + +namespace TakeoutSaaS.Application.App.Payments.Validators; + +/// +/// 创建支付记录命令验证器。 +/// +public sealed class CreatePaymentCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreatePaymentCommandValidator() + { + RuleFor(x => x.OrderId).GreaterThan(0); + RuleFor(x => x.Amount).GreaterThan(0); + RuleFor(x => x.Method) + .Must(method => method != PaymentMethod.Unknown) + .WithMessage("支付方式不可为空"); + RuleFor(x => x.TradeNo).MaximumLength(64); + RuleFor(x => x.ChannelTransactionId).MaximumLength(64); + RuleFor(x => x.Remark).MaximumLength(256); + + RuleFor(x => x.Status) + .Must(status => status is PaymentStatus.Unpaid or PaymentStatus.Paying or PaymentStatus.Paid or PaymentStatus.Failed or PaymentStatus.Refunded) + .WithMessage("支付状态不合法"); + When(x => x.Status == PaymentStatus.Paid, () => + { + RuleFor(x => x.PaidAt).NotNull().WithMessage("支付成功必须包含支付时间"); + }); + When(x => x.Status != PaymentStatus.Paid, () => + { + RuleFor(x => x.PaidAt).Must(paidAt => paidAt == null).WithMessage("非支付成功状态不应包含支付时间"); + }); + + When(x => x.Method is PaymentMethod.Cash or PaymentMethod.Card or PaymentMethod.Balance, () => + { + RuleFor(x => x.Status) + .Must(status => status is not PaymentStatus.Paying) + .WithMessage("线下/余额支付不允许处于 Paying 状态"); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Validators/SearchPaymentsQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Validators/SearchPaymentsQueryValidator.cs new file mode 100644 index 0000000..fe7580e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Validators/SearchPaymentsQueryValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Payments.Queries; + +namespace TakeoutSaaS.Application.App.Payments.Validators; + +/// +/// 支付记录查询验证器。 +/// +public sealed class SearchPaymentsQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public SearchPaymentsQueryValidator() + { + RuleFor(x => x.Page).GreaterThan(0); + RuleFor(x => x.PageSize).InclusiveBetween(1, 200); + RuleFor(x => x.SortBy).MaximumLength(64); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Validators/UpdatePaymentCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Validators/UpdatePaymentCommandValidator.cs new file mode 100644 index 0000000..65dfd1c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Validators/UpdatePaymentCommandValidator.cs @@ -0,0 +1,46 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Payments.Commands; +using TakeoutSaaS.Domain.Payments.Enums; + +namespace TakeoutSaaS.Application.App.Payments.Validators; + +/// +/// 更新支付记录命令验证器。 +/// +public sealed class UpdatePaymentCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdatePaymentCommandValidator() + { + RuleFor(x => x.PaymentId).GreaterThan(0); + RuleFor(x => x.OrderId).GreaterThan(0); + RuleFor(x => x.Amount).GreaterThan(0); + RuleFor(x => x.Method) + .Must(method => method != PaymentMethod.Unknown) + .WithMessage("支付方式不可为空"); + RuleFor(x => x.TradeNo).MaximumLength(64); + RuleFor(x => x.ChannelTransactionId).MaximumLength(64); + RuleFor(x => x.Remark).MaximumLength(256); + + RuleFor(x => x.Status) + .Must(status => status is PaymentStatus.Unpaid or PaymentStatus.Paying or PaymentStatus.Paid or PaymentStatus.Failed or PaymentStatus.Refunded) + .WithMessage("支付状态不合法"); + When(x => x.Status == PaymentStatus.Paid, () => + { + RuleFor(x => x.PaidAt).NotNull().WithMessage("支付成功必须包含支付时间"); + }); + When(x => x.Status != PaymentStatus.Paid, () => + { + RuleFor(x => x.PaidAt).Must(paidAt => paidAt == null).WithMessage("非支付成功状态不应包含支付时间"); + }); + + When(x => x.Method is PaymentMethod.Cash or PaymentMethod.Card or PaymentMethod.Balance, () => + { + RuleFor(x => x.Status) + .Must(status => status is not PaymentStatus.Paying) + .WithMessage("线下/余额支付不允许处于 Paying 状态"); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/CreateProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/CreateProductCommand.cs new file mode 100644 index 0000000..50d08c0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/CreateProductCommand.cs @@ -0,0 +1,101 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Enums; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 创建商品命令。 +/// +public sealed class CreateProductCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; set; } + + /// + /// 分类 ID。 + /// + public long CategoryId { get; set; } + + /// + /// 商品编码。 + /// + public string SpuCode { get; set; } = string.Empty; + + /// + /// 名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 副标题。 + /// + public string? Subtitle { get; set; } + + /// + /// 单位。 + /// + public string? Unit { get; set; } + + /// + /// 现价。 + /// + public decimal Price { get; set; } + + /// + /// 原价。 + /// + public decimal? OriginalPrice { get; set; } + + /// + /// 库存数量。 + /// + public int? StockQuantity { get; set; } + + /// + /// 每单限购。 + /// + public int? MaxQuantityPerOrder { get; set; } + + /// + /// 状态。 + /// + public ProductStatus Status { get; set; } = ProductStatus.Draft; + + /// + /// 主图。 + /// + public string? CoverImage { get; set; } + + /// + /// 图集。 + /// + public string? GalleryImages { get; set; } + + /// + /// 描述。 + /// + public string? Description { get; set; } + + /// + /// 支持堂食。 + /// + public bool EnableDineIn { get; set; } = true; + + /// + /// 支持自提。 + /// + public bool EnablePickup { get; set; } = true; + + /// + /// 支持配送。 + /// + public bool EnableDelivery { get; set; } = true; + + /// + /// 是否推荐。 + /// + public bool IsFeatured { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/DeleteProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/DeleteProductCommand.cs new file mode 100644 index 0000000..9a17c86 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/DeleteProductCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 删除商品命令。 +/// +public sealed class DeleteProductCommand : IRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/UpdateProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/UpdateProductCommand.cs new file mode 100644 index 0000000..09cd056 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/UpdateProductCommand.cs @@ -0,0 +1,106 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Enums; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 更新商品命令。 +/// +public sealed record UpdateProductCommand : IRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 分类 ID。 + /// + public long CategoryId { get; init; } + + /// + /// 商品编码。 + /// + public string SpuCode { get; init; } = string.Empty; + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 副标题。 + /// + public string? Subtitle { get; init; } + + /// + /// 单位。 + /// + public string? Unit { get; init; } + + /// + /// 现价。 + /// + public decimal Price { get; init; } + + /// + /// 原价。 + /// + public decimal? OriginalPrice { get; init; } + + /// + /// 库存数量。 + /// + public int? StockQuantity { get; init; } + + /// + /// 每单限购。 + /// + public int? MaxQuantityPerOrder { get; init; } + + /// + /// 状态。 + /// + public ProductStatus Status { get; init; } = ProductStatus.Draft; + + /// + /// 主图。 + /// + public string? CoverImage { get; init; } + + /// + /// 图集。 + /// + public string? GalleryImages { get; init; } + + /// + /// 描述。 + /// + public string? Description { get; init; } + + /// + /// 支持堂食。 + /// + public bool EnableDineIn { get; init; } = true; + + /// + /// 支持自提。 + /// + public bool EnablePickup { get; init; } = true; + + /// + /// 支持配送。 + /// + public bool EnableDelivery { get; init; } = true; + + /// + /// 是否推荐。 + /// + public bool IsFeatured { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs new file mode 100644 index 0000000..bfcd321 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs @@ -0,0 +1,120 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 商品 DTO。 +/// +public sealed class ProductDto +{ + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 分类 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long CategoryId { get; init; } + + /// + /// SPU 编码。 + /// + public string SpuCode { get; init; } = string.Empty; + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 副标题。 + /// + public string? Subtitle { get; init; } + + /// + /// 单位。 + /// + public string? Unit { get; init; } + + /// + /// 现价。 + /// + public decimal Price { get; init; } + + /// + /// 原价。 + /// + public decimal? OriginalPrice { get; init; } + + /// + /// 库存数量。 + /// + public int? StockQuantity { get; init; } + + /// + /// 每单限购。 + /// + public int? MaxQuantityPerOrder { get; init; } + + /// + /// 状态。 + /// + public ProductStatus Status { get; init; } + + /// + /// 主图。 + /// + public string? CoverImage { get; init; } + + /// + /// 图集。 + /// + public string? GalleryImages { get; init; } + + /// + /// 描述。 + /// + public string? Description { get; init; } + + /// + /// 支持堂食。 + /// + public bool EnableDineIn { get; init; } + + /// + /// 支持自提。 + /// + public bool EnablePickup { get; init; } + + /// + /// 支持配送。 + /// + public bool EnableDelivery { get; init; } + + /// + /// 是否推荐。 + /// + public bool IsFeatured { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/CreateProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/CreateProductCommandHandler.cs new file mode 100644 index 0000000..2bf1e33 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/CreateProductCommandHandler.cs @@ -0,0 +1,78 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 创建商品命令处理器。 +/// +public sealed class CreateProductCommandHandler(IProductRepository productRepository, ILogger logger) + : IRequestHandler +{ + private readonly IProductRepository _productRepository = productRepository; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreateProductCommand request, CancellationToken cancellationToken) + { + // 1. 构建实体 + var product = new Product + { + StoreId = request.StoreId, + CategoryId = request.CategoryId, + SpuCode = request.SpuCode.Trim(), + Name = request.Name.Trim(), + Subtitle = request.Subtitle?.Trim(), + Unit = request.Unit?.Trim(), + Price = request.Price, + OriginalPrice = request.OriginalPrice, + StockQuantity = request.StockQuantity, + MaxQuantityPerOrder = request.MaxQuantityPerOrder, + Status = request.Status, + CoverImage = request.CoverImage?.Trim(), + GalleryImages = request.GalleryImages?.Trim(), + Description = request.Description?.Trim(), + EnableDineIn = request.EnableDineIn, + EnablePickup = request.EnablePickup, + EnableDelivery = request.EnableDelivery, + IsFeatured = request.IsFeatured + }; + + // 2. 持久化 + await _productRepository.AddProductAsync(product, cancellationToken); + await _productRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("创建商品 {ProductId} - {ProductName}", product.Id, product.Name); + + // 3. 返回 DTO + return MapToDto(product); + } + + private static ProductDto MapToDto(Product product) => new() + { + Id = product.Id, + TenantId = product.TenantId, + StoreId = product.StoreId, + CategoryId = product.CategoryId, + SpuCode = product.SpuCode, + Name = product.Name, + Subtitle = product.Subtitle, + Unit = product.Unit, + Price = product.Price, + OriginalPrice = product.OriginalPrice, + StockQuantity = product.StockQuantity, + MaxQuantityPerOrder = product.MaxQuantityPerOrder, + Status = product.Status, + CoverImage = product.CoverImage, + GalleryImages = product.GalleryImages, + Description = product.Description, + EnableDineIn = product.EnableDineIn, + EnablePickup = product.EnablePickup, + EnableDelivery = product.EnableDelivery, + IsFeatured = product.IsFeatured, + CreatedAt = product.CreatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/DeleteProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/DeleteProductCommandHandler.cs new file mode 100644 index 0000000..f06cfa1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/DeleteProductCommandHandler.cs @@ -0,0 +1,40 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 删除商品命令处理器。 +/// +public sealed class DeleteProductCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IProductRepository _productRepository = productRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeleteProductCommand request, CancellationToken cancellationToken) + { + // 1. 校验存在性 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (existing == null) + { + return false; + } + + // 2. 删除 + await _productRepository.DeleteProductAsync(request.ProductId, tenantId, cancellationToken); + await _productRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除商品 {ProductId}", request.ProductId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs new file mode 100644 index 0000000..c3b6a60 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs @@ -0,0 +1,53 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Application.App.Products.Queries; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 商品详情查询处理器。 +/// +public sealed class GetProductByIdQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IProductRepository _productRepository = productRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task Handle(GetProductByIdQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var product = await _productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + return product == null ? null : MapToDto(product); + } + + private static ProductDto MapToDto(Product product) => new() + { + Id = product.Id, + TenantId = product.TenantId, + StoreId = product.StoreId, + CategoryId = product.CategoryId, + SpuCode = product.SpuCode, + Name = product.Name, + Subtitle = product.Subtitle, + Unit = product.Unit, + Price = product.Price, + OriginalPrice = product.OriginalPrice, + StockQuantity = product.StockQuantity, + MaxQuantityPerOrder = product.MaxQuantityPerOrder, + Status = product.Status, + CoverImage = product.CoverImage, + GalleryImages = product.GalleryImages, + Description = product.Description, + EnableDineIn = product.EnableDineIn, + EnablePickup = product.EnablePickup, + EnableDelivery = product.EnableDelivery, + IsFeatured = product.IsFeatured, + CreatedAt = product.CreatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs new file mode 100644 index 0000000..70e91f2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs @@ -0,0 +1,80 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Application.App.Products.Queries; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 商品列表查询处理器。 +/// +public sealed class SearchProductsQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IProductRepository _productRepository = productRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(SearchProductsQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var products = await _productRepository.SearchAsync(tenantId, request.CategoryId, request.Status, cancellationToken); + + if (request.StoreId.HasValue) + { + products = products.Where(x => x.StoreId == request.StoreId.Value).ToList(); + } + + var sorted = ApplySorting(products, request.SortBy, request.SortDescending); + var paged = sorted + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .ToList(); + + var items = paged.Select(MapToDto).ToList(); + return new PagedResult(items, request.Page, request.PageSize, products.Count); + } + + private static IOrderedEnumerable ApplySorting( + IReadOnlyCollection products, + string? sortBy, + bool sortDescending) + { + return sortBy?.ToLowerInvariant() switch + { + "name" => sortDescending ? products.OrderByDescending(x => x.Name) : products.OrderBy(x => x.Name), + "price" => sortDescending ? products.OrderByDescending(x => x.Price) : products.OrderBy(x => x.Price), + "status" => sortDescending ? products.OrderByDescending(x => x.Status) : products.OrderBy(x => x.Status), + _ => sortDescending ? products.OrderByDescending(x => x.CreatedAt) : products.OrderBy(x => x.CreatedAt) + }; + } + + private static ProductDto MapToDto(Domain.Products.Entities.Product product) => new() + { + Id = product.Id, + TenantId = product.TenantId, + StoreId = product.StoreId, + CategoryId = product.CategoryId, + SpuCode = product.SpuCode, + Name = product.Name, + Subtitle = product.Subtitle, + Unit = product.Unit, + Price = product.Price, + OriginalPrice = product.OriginalPrice, + StockQuantity = product.StockQuantity, + MaxQuantityPerOrder = product.MaxQuantityPerOrder, + Status = product.Status, + CoverImage = product.CoverImage, + GalleryImages = product.GalleryImages, + Description = product.Description, + EnableDineIn = product.EnableDineIn, + EnablePickup = product.EnablePickup, + EnableDelivery = product.EnableDelivery, + IsFeatured = product.IsFeatured, + CreatedAt = product.CreatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UpdateProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UpdateProductCommandHandler.cs new file mode 100644 index 0000000..e616788 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UpdateProductCommandHandler.cs @@ -0,0 +1,88 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 更新商品命令处理器。 +/// +public sealed class UpdateProductCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IProductRepository _productRepository = productRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdateProductCommand request, CancellationToken cancellationToken) + { + // 1. 读取商品 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (existing == null) + { + return null; + } + + // 2. 更新字段 + existing.StoreId = request.StoreId; + existing.CategoryId = request.CategoryId; + existing.SpuCode = request.SpuCode.Trim(); + existing.Name = request.Name.Trim(); + existing.Subtitle = request.Subtitle?.Trim(); + existing.Unit = request.Unit?.Trim(); + existing.Price = request.Price; + existing.OriginalPrice = request.OriginalPrice; + existing.StockQuantity = request.StockQuantity; + existing.MaxQuantityPerOrder = request.MaxQuantityPerOrder; + existing.Status = request.Status; + existing.CoverImage = request.CoverImage?.Trim(); + existing.GalleryImages = request.GalleryImages?.Trim(); + existing.Description = request.Description?.Trim(); + existing.EnableDineIn = request.EnableDineIn; + existing.EnablePickup = request.EnablePickup; + existing.EnableDelivery = request.EnableDelivery; + existing.IsFeatured = request.IsFeatured; + + // 3. 持久化 + await _productRepository.UpdateProductAsync(existing, cancellationToken); + await _productRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("更新商品 {ProductId} - {ProductName}", existing.Id, existing.Name); + + // 4. 返回 DTO + return MapToDto(existing); + } + + private static ProductDto MapToDto(Product product) => new() + { + Id = product.Id, + TenantId = product.TenantId, + StoreId = product.StoreId, + CategoryId = product.CategoryId, + SpuCode = product.SpuCode, + Name = product.Name, + Subtitle = product.Subtitle, + Unit = product.Unit, + Price = product.Price, + OriginalPrice = product.OriginalPrice, + StockQuantity = product.StockQuantity, + MaxQuantityPerOrder = product.MaxQuantityPerOrder, + Status = product.Status, + CoverImage = product.CoverImage, + GalleryImages = product.GalleryImages, + Description = product.Description, + EnableDineIn = product.EnableDineIn, + EnablePickup = product.EnablePickup, + EnableDelivery = product.EnableDelivery, + IsFeatured = product.IsFeatured, + CreatedAt = product.CreatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductByIdQuery.cs new file mode 100644 index 0000000..08830cb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductByIdQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Queries; + +/// +/// 获取商品详情查询。 +/// +public sealed class GetProductByIdQuery : IRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Queries/SearchProductsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Products/Queries/SearchProductsQuery.cs new file mode 100644 index 0000000..b1d4b31 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Queries/SearchProductsQuery.cs @@ -0,0 +1,47 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Products.Queries; + +/// +/// 商品列表查询。 +/// +public sealed class SearchProductsQuery : IRequest> +{ + /// + /// 门店 ID(可选)。 + /// + public long? StoreId { get; init; } + + /// + /// 分类 ID(可选)。 + /// + public long? CategoryId { get; init; } + + /// + /// 状态过滤。 + /// + public ProductStatus? Status { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段(name/price/status/createdAt)。 + /// + public string? SortBy { get; init; } + + /// + /// 是否倒序。 + /// + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/CreateProductCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/CreateProductCommandValidator.cs new file mode 100644 index 0000000..91bd6ac --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/CreateProductCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 创建商品命令验证器。 +/// +public sealed class CreateProductCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateProductCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.CategoryId).GreaterThan(0); + RuleFor(x => x.SpuCode).NotEmpty().MaximumLength(32); + RuleFor(x => x.Name).NotEmpty().MaximumLength(128); + RuleFor(x => x.Subtitle).MaximumLength(256); + RuleFor(x => x.Unit).MaximumLength(16); + RuleFor(x => x.Price).GreaterThanOrEqualTo(0); + RuleFor(x => x.OriginalPrice).GreaterThanOrEqualTo(0).When(x => x.OriginalPrice.HasValue); + RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.HasValue); + RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue); + RuleFor(x => x.CoverImage).MaximumLength(256); + RuleFor(x => x.GalleryImages).MaximumLength(1024); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/SearchProductsQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/SearchProductsQueryValidator.cs new file mode 100644 index 0000000..23e3962 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/SearchProductsQueryValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Queries; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 商品列表查询验证器。 +/// +public sealed class SearchProductsQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public SearchProductsQueryValidator() + { + RuleFor(x => x.Page).GreaterThan(0); + RuleFor(x => x.PageSize).InclusiveBetween(1, 200); + RuleFor(x => x.SortBy).MaximumLength(64); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/UpdateProductCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/UpdateProductCommandValidator.cs new file mode 100644 index 0000000..20200f2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/UpdateProductCommandValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 更新商品命令验证器。 +/// +public sealed class UpdateProductCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateProductCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.CategoryId).GreaterThan(0); + RuleFor(x => x.SpuCode).NotEmpty().MaximumLength(32); + RuleFor(x => x.Name).NotEmpty().MaximumLength(128); + RuleFor(x => x.Subtitle).MaximumLength(256); + RuleFor(x => x.Unit).MaximumLength(16); + RuleFor(x => x.Price).GreaterThanOrEqualTo(0); + RuleFor(x => x.OriginalPrice).GreaterThanOrEqualTo(0).When(x => x.OriginalPrice.HasValue); + RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.HasValue); + RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue); + RuleFor(x => x.CoverImage).MaximumLength(256); + RuleFor(x => x.GalleryImages).MaximumLength(1024); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs new file mode 100644 index 0000000..21eb9c5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs @@ -0,0 +1,101 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建门店命令。 +/// +public sealed class CreateStoreCommand : IRequest +{ + /// + /// 商户 ID。 + /// + public long MerchantId { get; set; } + + /// + /// 门店编码。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 门店名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 电话。 + /// + public string? Phone { get; set; } + + /// + /// 负责人。 + /// + public string? ManagerName { get; set; } + + /// + /// 状态。 + /// + public StoreStatus Status { get; set; } = StoreStatus.Closed; + + /// + /// 省份。 + /// + public string? Province { get; set; } + + /// + /// 城市。 + /// + public string? City { get; set; } + + /// + /// 区县。 + /// + public string? District { get; set; } + + /// + /// 详细地址。 + /// + public string? Address { get; set; } + + /// + /// 经度。 + /// + public double? Longitude { get; set; } + + /// + /// 纬度。 + /// + public double? Latitude { get; set; } + + /// + /// 公告。 + /// + public string? Announcement { get; set; } + + /// + /// 标签。 + /// + public string? Tags { get; set; } + + /// + /// 配送半径。 + /// + public decimal DeliveryRadiusKm { get; set; } + + /// + /// 支持堂食。 + /// + public bool SupportsDineIn { get; set; } = true; + + /// + /// 支持自提。 + /// + public bool SupportsPickup { get; set; } = true; + + /// + /// 支持配送。 + /// + public bool SupportsDelivery { get; set; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreCommand.cs new file mode 100644 index 0000000..9a66dbb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除门店命令。 +/// +public sealed class DeleteStoreCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs new file mode 100644 index 0000000..63a178d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs @@ -0,0 +1,106 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新门店命令。 +/// +public sealed record UpdateStoreCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 商户 ID。 + /// + public long MerchantId { get; init; } + + /// + /// 门店编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 门店名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 电话。 + /// + public string? Phone { get; init; } + + /// + /// 负责人。 + /// + public string? ManagerName { get; init; } + + /// + /// 状态。 + /// + public StoreStatus Status { get; init; } = StoreStatus.Closed; + + /// + /// 省份。 + /// + public string? Province { get; init; } + + /// + /// 城市。 + /// + public string? City { get; init; } + + /// + /// 区县。 + /// + public string? District { get; init; } + + /// + /// 详细地址。 + /// + public string? Address { get; init; } + + /// + /// 经度。 + /// + public double? Longitude { get; init; } + + /// + /// 纬度。 + /// + public double? Latitude { get; init; } + + /// + /// 公告。 + /// + public string? Announcement { get; init; } + + /// + /// 标签。 + /// + public string? Tags { get; init; } + + /// + /// 配送半径。 + /// + public decimal DeliveryRadiusKm { get; init; } + + /// + /// 支持堂食。 + /// + public bool SupportsDineIn { get; init; } = true; + + /// + /// 支持自提。 + /// + public bool SupportsPickup { get; init; } = true; + + /// + /// 支持配送。 + /// + public bool SupportsDelivery { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs new file mode 100644 index 0000000..5412717 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs @@ -0,0 +1,119 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店 DTO。 +/// +public sealed class StoreDto +{ + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 商户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long MerchantId { get; init; } + + /// + /// 门店编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 门店名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 电话。 + /// + public string? Phone { get; init; } + + /// + /// 负责人。 + /// + public string? ManagerName { get; init; } + + /// + /// 状态。 + /// + public StoreStatus Status { get; init; } + + /// + /// 省份。 + /// + public string? Province { get; init; } + + /// + /// 城市。 + /// + public string? City { get; init; } + + /// + /// 区县。 + /// + public string? District { get; init; } + + /// + /// 详细地址。 + /// + public string? Address { get; init; } + + /// + /// 经度。 + /// + public double? Longitude { get; init; } + + /// + /// 纬度。 + /// + public double? Latitude { get; init; } + + /// + /// 公告。 + /// + public string? Announcement { get; init; } + + /// + /// 标签。 + /// + public string? Tags { get; init; } + + /// + /// 默认配送半径。 + /// + public decimal DeliveryRadiusKm { get; init; } + + /// + /// 支持堂食。 + /// + public bool SupportsDineIn { get; init; } + + /// + /// 支持自提。 + /// + public bool SupportsPickup { get; init; } + + /// + /// 支持配送。 + /// + public bool SupportsDelivery { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs new file mode 100644 index 0000000..f254d6f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs @@ -0,0 +1,78 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建门店命令处理器。 +/// +public sealed class CreateStoreCommandHandler(IStoreRepository storeRepository, ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreateStoreCommand request, CancellationToken cancellationToken) + { + // 1. 构建实体 + var store = new Store + { + MerchantId = request.MerchantId, + Code = request.Code.Trim(), + Name = request.Name.Trim(), + Phone = request.Phone?.Trim(), + ManagerName = request.ManagerName?.Trim(), + Status = request.Status, + Province = request.Province?.Trim(), + City = request.City?.Trim(), + District = request.District?.Trim(), + Address = request.Address?.Trim(), + Longitude = request.Longitude, + Latitude = request.Latitude, + Announcement = request.Announcement?.Trim(), + Tags = request.Tags?.Trim(), + DeliveryRadiusKm = request.DeliveryRadiusKm, + SupportsDineIn = request.SupportsDineIn, + SupportsPickup = request.SupportsPickup, + SupportsDelivery = request.SupportsDelivery + }; + + // 2. 持久化 + await _storeRepository.AddStoreAsync(store, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("创建门店 {StoreId} - {StoreName}", store.Id, store.Name); + + // 3. 返回 DTO + return MapToDto(store); + } + + private static StoreDto MapToDto(Store store) => new() + { + Id = store.Id, + TenantId = store.TenantId, + MerchantId = store.MerchantId, + Code = store.Code, + Name = store.Name, + Phone = store.Phone, + ManagerName = store.ManagerName, + Status = store.Status, + Province = store.Province, + City = store.City, + District = store.District, + Address = store.Address, + Longitude = store.Longitude, + Latitude = store.Latitude, + Announcement = store.Announcement, + Tags = store.Tags, + DeliveryRadiusKm = store.DeliveryRadiusKm, + SupportsDineIn = store.SupportsDineIn, + SupportsPickup = store.SupportsPickup, + SupportsDelivery = store.SupportsDelivery, + CreatedAt = store.CreatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreCommandHandler.cs new file mode 100644 index 0000000..7e30eaf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreCommandHandler.cs @@ -0,0 +1,40 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除门店命令处理器。 +/// +public sealed class DeleteStoreCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeleteStoreCommand request, CancellationToken cancellationToken) + { + // 1. 校验存在性 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (existing == null) + { + return false; + } + + // 2. 删除 + await _storeRepository.DeleteStoreAsync(request.StoreId, tenantId, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除门店 {StoreId}", request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs new file mode 100644 index 0000000..995ddde --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs @@ -0,0 +1,53 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 门店详情查询处理器。 +/// +public sealed class GetStoreByIdQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task Handle(GetStoreByIdQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var store = await _storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + return store == null ? null : MapToDto(store); + } + + private static StoreDto MapToDto(Store store) => new() + { + Id = store.Id, + TenantId = store.TenantId, + MerchantId = store.MerchantId, + Code = store.Code, + Name = store.Name, + Phone = store.Phone, + ManagerName = store.ManagerName, + Status = store.Status, + Province = store.Province, + City = store.City, + District = store.District, + Address = store.Address, + Longitude = store.Longitude, + Latitude = store.Latitude, + Announcement = store.Announcement, + Tags = store.Tags, + DeliveryRadiusKm = store.DeliveryRadiusKm, + SupportsDineIn = store.SupportsDineIn, + SupportsPickup = store.SupportsPickup, + SupportsDelivery = store.SupportsDelivery, + CreatedAt = store.CreatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs new file mode 100644 index 0000000..1bb609d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs @@ -0,0 +1,80 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 门店列表查询处理器。 +/// +public sealed class SearchStoresQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(SearchStoresQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var stores = await _storeRepository.SearchAsync(tenantId, request.Status, cancellationToken); + + if (request.MerchantId.HasValue) + { + stores = stores.Where(x => x.MerchantId == request.MerchantId.Value).ToList(); + } + + var sorted = ApplySorting(stores, request.SortBy, request.SortDescending); + var paged = sorted + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .ToList(); + + var items = paged.Select(MapToDto).ToList(); + return new PagedResult(items, request.Page, request.PageSize, stores.Count); + } + + private static IOrderedEnumerable ApplySorting( + IReadOnlyCollection stores, + string? sortBy, + bool sortDescending) + { + return sortBy?.ToLowerInvariant() switch + { + "name" => sortDescending ? stores.OrderByDescending(x => x.Name) : stores.OrderBy(x => x.Name), + "code" => sortDescending ? stores.OrderByDescending(x => x.Code) : stores.OrderBy(x => x.Code), + "status" => sortDescending ? stores.OrderByDescending(x => x.Status) : stores.OrderBy(x => x.Status), + _ => sortDescending ? stores.OrderByDescending(x => x.CreatedAt) : stores.OrderBy(x => x.CreatedAt) + }; + } + + private static StoreDto MapToDto(Domain.Stores.Entities.Store store) => new() + { + Id = store.Id, + TenantId = store.TenantId, + MerchantId = store.MerchantId, + Code = store.Code, + Name = store.Name, + Phone = store.Phone, + ManagerName = store.ManagerName, + Status = store.Status, + Province = store.Province, + City = store.City, + District = store.District, + Address = store.Address, + Longitude = store.Longitude, + Latitude = store.Latitude, + Announcement = store.Announcement, + Tags = store.Tags, + DeliveryRadiusKm = store.DeliveryRadiusKm, + SupportsDineIn = store.SupportsDineIn, + SupportsPickup = store.SupportsPickup, + SupportsDelivery = store.SupportsDelivery, + CreatedAt = store.CreatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs new file mode 100644 index 0000000..934bb54 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs @@ -0,0 +1,88 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新门店命令处理器。 +/// +public sealed class UpdateStoreCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdateStoreCommand request, CancellationToken cancellationToken) + { + // 1. 读取门店 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (existing == null) + { + return null; + } + + // 2. 更新字段 + existing.MerchantId = request.MerchantId; + existing.Code = request.Code.Trim(); + existing.Name = request.Name.Trim(); + existing.Phone = request.Phone?.Trim(); + existing.ManagerName = request.ManagerName?.Trim(); + existing.Status = request.Status; + existing.Province = request.Province?.Trim(); + existing.City = request.City?.Trim(); + existing.District = request.District?.Trim(); + existing.Address = request.Address?.Trim(); + existing.Longitude = request.Longitude; + existing.Latitude = request.Latitude; + existing.Announcement = request.Announcement?.Trim(); + existing.Tags = request.Tags?.Trim(); + existing.DeliveryRadiusKm = request.DeliveryRadiusKm; + existing.SupportsDineIn = request.SupportsDineIn; + existing.SupportsPickup = request.SupportsPickup; + existing.SupportsDelivery = request.SupportsDelivery; + + // 3. 持久化 + await _storeRepository.UpdateStoreAsync(existing, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("更新门店 {StoreId} - {StoreName}", existing.Id, existing.Name); + + // 4. 返回 DTO + return MapToDto(existing); + } + + private static StoreDto MapToDto(Store store) => new() + { + Id = store.Id, + TenantId = store.TenantId, + MerchantId = store.MerchantId, + Code = store.Code, + Name = store.Name, + Phone = store.Phone, + ManagerName = store.ManagerName, + Status = store.Status, + Province = store.Province, + City = store.City, + District = store.District, + Address = store.Address, + Longitude = store.Longitude, + Latitude = store.Latitude, + Announcement = store.Announcement, + Tags = store.Tags, + DeliveryRadiusKm = store.DeliveryRadiusKm, + SupportsDineIn = store.SupportsDineIn, + SupportsPickup = store.SupportsPickup, + SupportsDelivery = store.SupportsDelivery, + CreatedAt = store.CreatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreByIdQuery.cs new file mode 100644 index 0000000..7f0699e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreByIdQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 获取门店详情查询。 +/// +public sealed class GetStoreByIdQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/SearchStoresQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/SearchStoresQuery.cs new file mode 100644 index 0000000..f429439 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/SearchStoresQuery.cs @@ -0,0 +1,42 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 门店列表查询。 +/// +public sealed class SearchStoresQuery : IRequest> +{ + /// + /// 商户 ID(可选)。 + /// + public long? MerchantId { get; init; } + + /// + /// 状态过滤。 + /// + public StoreStatus? Status { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段(name/code/status/createdAt)。 + /// + public string? SortBy { get; init; } + + /// + /// 是否倒序。 + /// + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreCommandValidator.cs new file mode 100644 index 0000000..a947ae2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreCommandValidator.cs @@ -0,0 +1,29 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建门店命令验证器。 +/// +public sealed class CreateStoreCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreCommandValidator() + { + RuleFor(x => x.MerchantId).GreaterThan(0); + RuleFor(x => x.Code).NotEmpty().MaximumLength(32); + RuleFor(x => x.Name).NotEmpty().MaximumLength(128); + RuleFor(x => x.Phone).MaximumLength(32); + RuleFor(x => x.ManagerName).MaximumLength(64); + RuleFor(x => x.Province).MaximumLength(64); + RuleFor(x => x.City).MaximumLength(64); + RuleFor(x => x.District).MaximumLength(64); + RuleFor(x => x.Address).MaximumLength(256); + RuleFor(x => x.Announcement).MaximumLength(512); + RuleFor(x => x.Tags).MaximumLength(256); + RuleFor(x => x.DeliveryRadiusKm).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/SearchStoresQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/SearchStoresQueryValidator.cs new file mode 100644 index 0000000..67ea047 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/SearchStoresQueryValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Queries; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 门店列表查询验证器。 +/// +public sealed class SearchStoresQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public SearchStoresQueryValidator() + { + RuleFor(x => x.Page).GreaterThan(0); + RuleFor(x => x.PageSize).InclusiveBetween(1, 200); + RuleFor(x => x.SortBy).MaximumLength(64); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreCommandValidator.cs new file mode 100644 index 0000000..d021aae --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreCommandValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新门店命令验证器。 +/// +public sealed class UpdateStoreCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.MerchantId).GreaterThan(0); + RuleFor(x => x.Code).NotEmpty().MaximumLength(32); + RuleFor(x => x.Name).NotEmpty().MaximumLength(128); + RuleFor(x => x.Phone).MaximumLength(32); + RuleFor(x => x.ManagerName).MaximumLength(64); + RuleFor(x => x.Province).MaximumLength(64); + RuleFor(x => x.City).MaximumLength(64); + RuleFor(x => x.District).MaximumLength(64); + RuleFor(x => x.Address).MaximumLength(256); + RuleFor(x => x.Announcement).MaximumLength(512); + RuleFor(x => x.Tags).MaximumLength(256); + RuleFor(x => x.DeliveryRadiusKm).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Commands/CreateSystemParameterCommand.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Commands/CreateSystemParameterCommand.cs new file mode 100644 index 0000000..c5f332c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Commands/CreateSystemParameterCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.SystemParameters.Dto; + +namespace TakeoutSaaS.Application.App.SystemParameters.Commands; + +/// +/// 创建系统参数命令。 +/// +public sealed class CreateSystemParameterCommand : IRequest +{ + /// + /// 参数键。 + /// + public string Key { get; set; } = string.Empty; + + /// + /// 参数值。 + /// + public string Value { get; set; } = string.Empty; + + /// + /// 描述。 + /// + public string? Description { get; set; } + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Commands/DeleteSystemParameterCommand.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Commands/DeleteSystemParameterCommand.cs new file mode 100644 index 0000000..6f49ad8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Commands/DeleteSystemParameterCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.SystemParameters.Commands; + +/// +/// 删除系统参数命令。 +/// +public sealed record DeleteSystemParameterCommand : IRequest +{ + /// + /// 参数 ID。 + /// + public long ParameterId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Commands/UpdateSystemParameterCommand.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Commands/UpdateSystemParameterCommand.cs new file mode 100644 index 0000000..86bbfa7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Commands/UpdateSystemParameterCommand.cs @@ -0,0 +1,40 @@ +using MediatR; +using TakeoutSaaS.Application.App.SystemParameters.Dto; + +namespace TakeoutSaaS.Application.App.SystemParameters.Commands; + +/// +/// 更新系统参数命令。 +/// +public sealed record UpdateSystemParameterCommand : IRequest +{ + /// + /// 参数 ID。 + /// + public long ParameterId { get; init; } + + /// + /// 参数键。 + /// + public string Key { get; init; } = string.Empty; + + /// + /// 参数值。 + /// + public string Value { get; init; } = string.Empty; + + /// + /// 描述。 + /// + public string? Description { get; init; } + + /// + /// 排序值。 + /// + public int SortOrder { get; init; } = 100; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Dto/SystemParameterDto.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Dto/SystemParameterDto.cs new file mode 100644 index 0000000..8305ae4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Dto/SystemParameterDto.cs @@ -0,0 +1,57 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.SystemParameters.Dto; + +/// +/// 系统参数 DTO。 +/// +public sealed class SystemParameterDto +{ + /// + /// 参数 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 参数键。 + /// + public string Key { get; init; } = string.Empty; + + /// + /// 参数值。 + /// + public string Value { get; init; } = string.Empty; + + /// + /// 描述。 + /// + public string? Description { get; init; } + + /// + /// 排序值。 + /// + public int SortOrder { get; init; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 最近更新时间。 + /// + public DateTime? UpdatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/CreateSystemParameterCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/CreateSystemParameterCommandHandler.cs new file mode 100644 index 0000000..64ab3c1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/CreateSystemParameterCommandHandler.cs @@ -0,0 +1,64 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.SystemParameters.Commands; +using TakeoutSaaS.Application.App.SystemParameters.Dto; +using TakeoutSaaS.Domain.SystemParameters.Entities; +using TakeoutSaaS.Domain.SystemParameters.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.SystemParameters.Handlers; + +/// +/// 创建系统参数命令处理器。 +/// +public sealed class CreateSystemParameterCommandHandler( + ISystemParameterRepository repository, + ILogger logger) + : IRequestHandler +{ + private readonly ISystemParameterRepository _repository = repository; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreateSystemParameterCommand request, CancellationToken cancellationToken) + { + // 1. 唯一性校验 + var existing = await _repository.FindByKeyAsync(request.Key, cancellationToken); + if (existing != null) + { + throw new BusinessException(ErrorCodes.Conflict, "系统参数键已存在"); + } + + // 2. 构建实体 + var parameter = new SystemParameter + { + Key = request.Key.Trim(), + Value = request.Value.Trim(), + Description = request.Description?.Trim(), + SortOrder = request.SortOrder, + IsEnabled = request.IsEnabled + }; + + // 3. 持久化 + await _repository.AddAsync(parameter, cancellationToken); + await _repository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("创建系统参数 {Key}", parameter.Key); + + // 4. 映射返回 + return MapToDto(parameter); + } + + private static SystemParameterDto MapToDto(SystemParameter parameter) => new() + { + Id = parameter.Id, + TenantId = parameter.TenantId, + Key = parameter.Key, + Value = parameter.Value, + Description = parameter.Description, + SortOrder = parameter.SortOrder, + IsEnabled = parameter.IsEnabled, + CreatedAt = parameter.CreatedAt, + UpdatedAt = parameter.UpdatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/DeleteSystemParameterCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/DeleteSystemParameterCommandHandler.cs new file mode 100644 index 0000000..cda2c42 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/DeleteSystemParameterCommandHandler.cs @@ -0,0 +1,33 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.SystemParameters.Commands; +using TakeoutSaaS.Domain.SystemParameters.Repositories; + +namespace TakeoutSaaS.Application.App.SystemParameters.Handlers; + +/// +/// 删除系统参数命令处理器。 +/// +public sealed class DeleteSystemParameterCommandHandler( + ISystemParameterRepository repository, + ILogger logger) + : IRequestHandler +{ + private readonly ISystemParameterRepository _repository = repository; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeleteSystemParameterCommand request, CancellationToken cancellationToken) + { + var existing = await _repository.FindByIdAsync(request.ParameterId, cancellationToken); + if (existing == null) + { + return false; + } + + await _repository.RemoveAsync(existing, cancellationToken); + await _repository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除系统参数 {Key}", existing.Key); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/GetSystemParameterByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/GetSystemParameterByIdQueryHandler.cs new file mode 100644 index 0000000..c2bc53a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/GetSystemParameterByIdQueryHandler.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.SystemParameters.Dto; +using TakeoutSaaS.Application.App.SystemParameters.Queries; +using TakeoutSaaS.Domain.SystemParameters.Repositories; + +namespace TakeoutSaaS.Application.App.SystemParameters.Handlers; + +/// +/// 获取系统参数详情查询处理器。 +/// +public sealed class GetSystemParameterByIdQueryHandler(ISystemParameterRepository repository) + : IRequestHandler +{ + private readonly ISystemParameterRepository _repository = repository; + + /// + public async Task Handle(GetSystemParameterByIdQuery request, CancellationToken cancellationToken) + { + var parameter = await _repository.FindByIdAsync(request.ParameterId, cancellationToken); + return parameter == null ? null : MapToDto(parameter); + } + + private static SystemParameterDto MapToDto(Domain.SystemParameters.Entities.SystemParameter parameter) => new() + { + Id = parameter.Id, + TenantId = parameter.TenantId, + Key = parameter.Key, + Value = parameter.Value, + Description = parameter.Description, + SortOrder = parameter.SortOrder, + IsEnabled = parameter.IsEnabled, + CreatedAt = parameter.CreatedAt, + UpdatedAt = parameter.UpdatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/SearchSystemParametersQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/SearchSystemParametersQueryHandler.cs new file mode 100644 index 0000000..678b55c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/SearchSystemParametersQueryHandler.cs @@ -0,0 +1,69 @@ +using MediatR; +using TakeoutSaaS.Application.App.SystemParameters.Dto; +using TakeoutSaaS.Application.App.SystemParameters.Queries; +using TakeoutSaaS.Domain.SystemParameters.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.SystemParameters.Handlers; + +/// +/// 系统参数列表查询处理器。 +/// +public sealed class SearchSystemParametersQueryHandler(ISystemParameterRepository repository) + : IRequestHandler> +{ + private readonly ISystemParameterRepository _repository = repository; + + /// + public async Task> Handle(SearchSystemParametersQuery request, CancellationToken cancellationToken) + { + var parameters = await _repository.SearchAsync(request.Keyword, request.IsEnabled, cancellationToken); + + var sorted = ApplySorting(parameters, request.SortBy, request.SortDescending); + var paged = sorted + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .ToList(); + + var items = paged.Select(MapToDto).ToList(); + return new PagedResult(items, request.Page, request.PageSize, parameters.Count); + } + + private static IOrderedEnumerable ApplySorting( + IReadOnlyCollection parameters, + string? sortBy, + bool sortDescending) + { + return sortBy?.ToLowerInvariant() switch + { + "key" => sortDescending + ? parameters.OrderByDescending(x => x.Key) + : parameters.OrderBy(x => x.Key), + "sortorder" => sortDescending + ? parameters.OrderByDescending(x => x.SortOrder) + : parameters.OrderBy(x => x.SortOrder), + "updatedat" => sortDescending + ? parameters.OrderByDescending(x => x.UpdatedAt ?? x.CreatedAt) + : parameters.OrderBy(x => x.UpdatedAt ?? x.CreatedAt), + "isenabled" => sortDescending + ? parameters.OrderByDescending(x => x.IsEnabled) + : parameters.OrderBy(x => x.IsEnabled), + _ => sortDescending + ? parameters.OrderByDescending(x => x.CreatedAt) + : parameters.OrderBy(x => x.CreatedAt) + }; + } + + private static SystemParameterDto MapToDto(Domain.SystemParameters.Entities.SystemParameter parameter) => new() + { + Id = parameter.Id, + TenantId = parameter.TenantId, + Key = parameter.Key, + Value = parameter.Value, + Description = parameter.Description, + SortOrder = parameter.SortOrder, + IsEnabled = parameter.IsEnabled, + CreatedAt = parameter.CreatedAt, + UpdatedAt = parameter.UpdatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/UpdateSystemParameterCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/UpdateSystemParameterCommandHandler.cs new file mode 100644 index 0000000..6ba3104 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Handlers/UpdateSystemParameterCommandHandler.cs @@ -0,0 +1,66 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.SystemParameters.Commands; +using TakeoutSaaS.Application.App.SystemParameters.Dto; +using TakeoutSaaS.Domain.SystemParameters.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.SystemParameters.Handlers; + +/// +/// 更新系统参数命令处理器。 +/// +public sealed class UpdateSystemParameterCommandHandler( + ISystemParameterRepository repository, + ILogger logger) + : IRequestHandler +{ + private readonly ISystemParameterRepository _repository = repository; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdateSystemParameterCommand request, CancellationToken cancellationToken) + { + // 1. 读取已有参数 + var existing = await _repository.FindByIdAsync(request.ParameterId, cancellationToken); + if (existing == null) + { + return null; + } + + // 2. 唯一性校验 + var duplicate = await _repository.FindByKeyAsync(request.Key, cancellationToken); + if (duplicate != null && duplicate.Id != existing.Id) + { + throw new BusinessException(ErrorCodes.Conflict, "系统参数键已存在"); + } + + // 3. 更新字段 + existing.Key = request.Key.Trim(); + existing.Value = request.Value.Trim(); + existing.Description = request.Description?.Trim(); + existing.SortOrder = request.SortOrder; + existing.IsEnabled = request.IsEnabled; + + // 4. 持久化 + await _repository.UpdateAsync(existing, cancellationToken); + await _repository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("更新系统参数 {Key}", existing.Key); + + return MapToDto(existing); + } + + private static SystemParameterDto MapToDto(Domain.SystemParameters.Entities.SystemParameter parameter) => new() + { + Id = parameter.Id, + TenantId = parameter.TenantId, + Key = parameter.Key, + Value = parameter.Value, + Description = parameter.Description, + SortOrder = parameter.SortOrder, + IsEnabled = parameter.IsEnabled, + CreatedAt = parameter.CreatedAt, + UpdatedAt = parameter.UpdatedAt + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Queries/GetSystemParameterByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Queries/GetSystemParameterByIdQuery.cs new file mode 100644 index 0000000..09c6b70 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Queries/GetSystemParameterByIdQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.SystemParameters.Dto; + +namespace TakeoutSaaS.Application.App.SystemParameters.Queries; + +/// +/// 获取系统参数详情查询。 +/// +public sealed record GetSystemParameterByIdQuery(long ParameterId) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Queries/SearchSystemParametersQuery.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Queries/SearchSystemParametersQuery.cs new file mode 100644 index 0000000..d6e0238 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Queries/SearchSystemParametersQuery.cs @@ -0,0 +1,41 @@ +using MediatR; +using TakeoutSaaS.Application.App.SystemParameters.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.SystemParameters.Queries; + +/// +/// 系统参数列表查询。 +/// +public sealed class SearchSystemParametersQuery : IRequest> +{ + /// + /// 关键字(匹配 Key/Description)。 + /// + public string? Keyword { get; init; } + + /// + /// 启用状态过滤。 + /// + public bool? IsEnabled { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段(key/sortOrder/createdAt/updatedAt/isEnabled)。 + /// + public string? SortBy { get; init; } + + /// + /// 是否倒序。 + /// + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Validators/CreateSystemParameterCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Validators/CreateSystemParameterCommandValidator.cs new file mode 100644 index 0000000..a624833 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Validators/CreateSystemParameterCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.SystemParameters.Commands; + +namespace TakeoutSaaS.Application.App.SystemParameters.Validators; + +/// +/// 创建系统参数命令验证器。 +/// +public sealed class CreateSystemParameterCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateSystemParameterCommandValidator() + { + RuleFor(x => x.Key).NotEmpty().MaximumLength(128); + RuleFor(x => x.Value).NotEmpty(); + RuleFor(x => x.Description).MaximumLength(512); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Validators/SearchSystemParametersQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Validators/SearchSystemParametersQueryValidator.cs new file mode 100644 index 0000000..83586ab --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Validators/SearchSystemParametersQueryValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.SystemParameters.Queries; + +namespace TakeoutSaaS.Application.App.SystemParameters.Validators; + +/// +/// 系统参数列表查询验证器。 +/// +public sealed class SearchSystemParametersQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public SearchSystemParametersQueryValidator() + { + RuleFor(x => x.Page).GreaterThan(0); + RuleFor(x => x.PageSize).InclusiveBetween(1, 200); + RuleFor(x => x.Keyword).MaximumLength(256); + RuleFor(x => x.SortBy).MaximumLength(64); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/SystemParameters/Validators/UpdateSystemParameterCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Validators/UpdateSystemParameterCommandValidator.cs new file mode 100644 index 0000000..831ad11 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/SystemParameters/Validators/UpdateSystemParameterCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.SystemParameters.Commands; + +namespace TakeoutSaaS.Application.App.SystemParameters.Validators; + +/// +/// 更新系统参数命令验证器。 +/// +public sealed class UpdateSystemParameterCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateSystemParameterCommandValidator() + { + RuleFor(x => x.ParameterId).GreaterThan(0); + RuleFor(x => x.Key).NotEmpty().MaximumLength(128); + RuleFor(x => x.Value).NotEmpty(); + RuleFor(x => x.Description).MaximumLength(512); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryAppService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryAppService.cs new file mode 100644 index 0000000..60e5f61 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryAppService.cs @@ -0,0 +1,26 @@ +using TakeoutSaaS.Application.Dictionary.Contracts; +using TakeoutSaaS.Application.Dictionary.Models; + +namespace TakeoutSaaS.Application.Dictionary.Abstractions; + +/// +/// 参数字典应用服务接口。 +/// +public interface IDictionaryAppService +{ + Task CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default); + + Task UpdateGroupAsync(long groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default); + + Task DeleteGroupAsync(long groupId, CancellationToken cancellationToken = default); + + Task> SearchGroupsAsync(DictionaryGroupQuery request, CancellationToken cancellationToken = default); + + Task CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default); + + Task UpdateItemAsync(long itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default); + + Task DeleteItemAsync(long itemId, CancellationToken cancellationToken = default); + + Task>> GetCachedItemsAsync(DictionaryBatchQueryRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs new file mode 100644 index 0000000..ebdc59f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Dictionary.Models; + +namespace TakeoutSaaS.Application.Dictionary.Abstractions; + +/// +/// 字典缓存读写接口。 +/// +public interface IDictionaryCache +{ + /// + /// 获取缓存。 + /// + Task?> GetAsync(long tenantId, string code, CancellationToken cancellationToken = default); + + /// + /// 写入缓存。 + /// + Task SetAsync(long tenantId, string code, IReadOnlyList items, CancellationToken cancellationToken = default); + + /// + /// 移除缓存。 + /// + Task RemoveAsync(long tenantId, string code, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryGroupRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryGroupRequest.cs new file mode 100644 index 0000000..10454ff --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryGroupRequest.cs @@ -0,0 +1,34 @@ +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Domain.Dictionary.Enums; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 创建字典分组请求。 +/// +public sealed class CreateDictionaryGroupRequest +{ + /// + /// 分组编码。 + /// + [Required, MaxLength(64)] + public string Code { get; set; } = string.Empty; + + /// + /// 分组名称。 + /// + [Required, MaxLength(128)] + public string Name { get; set; } = string.Empty; + + /// + /// 作用域:系统/业务。 + /// + [Required] + public DictionaryScope Scope { get; set; } = DictionaryScope.Business; + + /// + /// 描述信息。 + /// + [MaxLength(512)] + public string? Description { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs new file mode 100644 index 0000000..668d369 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 创建字典项请求。 +/// +public sealed class CreateDictionaryItemRequest +{ + /// + /// 所属分组 ID。 + /// + [Required] + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long GroupId { get; set; } + + /// + /// 字典项键。 + /// + [Required, MaxLength(64)] + public string Key { get; set; } = string.Empty; + + /// + /// 字典项值。 + /// + [Required, MaxLength(256)] + public string Value { get; set; } = string.Empty; + + /// + /// 是否默认项。 + /// + public bool IsDefault { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; + + /// + /// 描述信息。 + /// + [MaxLength(512)] + public string? Description { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryBatchQueryRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryBatchQueryRequest.cs new file mode 100644 index 0000000..cc5e2c6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryBatchQueryRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 批量查询字典项请求。 +/// +public sealed class DictionaryBatchQueryRequest +{ + /// + /// 分组编码集合。 + /// + [Required] + public IReadOnlyCollection Codes { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryGroupQuery.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryGroupQuery.cs new file mode 100644 index 0000000..861b082 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/DictionaryGroupQuery.cs @@ -0,0 +1,19 @@ +using TakeoutSaaS.Domain.Dictionary.Enums; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 字典分组查询参数。 +/// +public sealed class DictionaryGroupQuery +{ + /// + /// 作用域过滤。 + /// + public DictionaryScope? Scope { get; set; } + + /// + /// 是否包含字典项。 + /// + public bool IncludeItems { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs new file mode 100644 index 0000000..4ed0fdc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryGroupRequest.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 更新字典分组请求。 +/// +public sealed class UpdateDictionaryGroupRequest +{ + /// + /// 分组名称。 + /// + [Required, MaxLength(128)] + public string Name { get; set; } = string.Empty; + + /// + /// 描述信息。 + /// + [MaxLength(512)] + public string? Description { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs new file mode 100644 index 0000000..f2c9871 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/UpdateDictionaryItemRequest.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Dictionary.Contracts; + +/// +/// 更新字典项请求。 +/// +public sealed class UpdateDictionaryItemRequest +{ + /// + /// 字典项值。 + /// + [Required, MaxLength(256)] + public string Value { get; set; } = string.Empty; + + /// + /// 是否默认项。 + /// + public bool IsDefault { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; + + /// + /// 描述信息。 + /// + [MaxLength(512)] + public string? Description { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs new file mode 100644 index 0000000..5b03aa0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Dictionary.Abstractions; +using TakeoutSaaS.Application.Dictionary.Services; + +namespace TakeoutSaaS.Application.Dictionary.Extensions; + +/// +/// 字典应用服务注册扩展。 +/// +public static class DictionaryServiceCollectionExtensions +{ + /// + /// 注册字典模块应用层组件。 + /// + public static IServiceCollection AddDictionaryApplication(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs new file mode 100644 index 0000000..95a81f0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs @@ -0,0 +1,27 @@ +using TakeoutSaaS.Domain.Dictionary.Enums; + +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.Dictionary.Models; + +/// +/// 字典分组 DTO。 +/// +public sealed class DictionaryGroupDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + public string Code { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public DictionaryScope Scope { get; init; } + + public string? Description { get; init; } + + public bool IsEnabled { get; init; } + + public IReadOnlyList Items { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs new file mode 100644 index 0000000..f154f3e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryItemDto.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.Dictionary.Models; + +/// +/// 字典项 DTO。 +/// +public sealed class DictionaryItemDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long GroupId { get; init; } + + public string Key { get; init; } = string.Empty; + + public string Value { get; init; } = string.Empty; + + public bool IsDefault { get; init; } + + public bool IsEnabled { get; init; } + + public int SortOrder { get; init; } + + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs new file mode 100644 index 0000000..5ff8e9a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs @@ -0,0 +1,332 @@ +using System.Linq; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.Dictionary.Abstractions; +using TakeoutSaaS.Application.Dictionary.Contracts; +using TakeoutSaaS.Application.Dictionary.Models; +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Domain.Dictionary.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Dictionary.Services; + +/// +/// 参数字典应用服务实现。 +/// +public sealed class DictionaryAppService( + IDictionaryRepository repository, + IDictionaryCache cache, + ITenantProvider tenantProvider, + ILogger logger) : IDictionaryAppService +{ + + public async Task CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default) + { + var normalizedCode = NormalizeCode(request.Code); + var targetTenant = ResolveTargetTenant(request.Scope); + + var existing = await repository.FindGroupByCodeAsync(normalizedCode, cancellationToken); + if (existing != null) + { + throw new BusinessException(ErrorCodes.Conflict, $"字典分组编码 {normalizedCode} 已存在"); + } + + var group = new DictionaryGroup + { + Id = 0, + TenantId = targetTenant, + Code = normalizedCode, + Name = request.Name.Trim(), + Scope = request.Scope, + Description = request.Description?.Trim(), + IsEnabled = true + }; + + await repository.AddGroupAsync(group, cancellationToken); + await repository.SaveChangesAsync(cancellationToken); + logger.LogInformation("创建字典分组:{Code}({Scope})", group.Code, group.Scope); + return MapGroup(group, includeItems: false); + } + + public async Task UpdateGroupAsync(long groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default) + { + var group = await RequireGroupAsync(groupId, cancellationToken); + EnsureScopePermission(group.Scope); + + group.Name = request.Name.Trim(); + group.Description = request.Description?.Trim(); + group.IsEnabled = request.IsEnabled; + + await repository.SaveChangesAsync(cancellationToken); + await InvalidateCacheAsync(group, cancellationToken); + logger.LogInformation("更新字典分组:{GroupId}", group.Id); + return MapGroup(group, includeItems: false); + } + + public async Task DeleteGroupAsync(long groupId, CancellationToken cancellationToken = default) + { + var group = await RequireGroupAsync(groupId, cancellationToken); + EnsureScopePermission(group.Scope); + + await repository.RemoveGroupAsync(group, cancellationToken); + await repository.SaveChangesAsync(cancellationToken); + await InvalidateCacheAsync(group, cancellationToken); + logger.LogInformation("删除字典分组:{GroupId}", group.Id); + } + + public async Task> SearchGroupsAsync(DictionaryGroupQuery request, CancellationToken cancellationToken = default) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var scope = ResolveScopeForQuery(request.Scope, tenantId); + EnsureScopePermission(scope); + + var groups = await repository.SearchGroupsAsync(scope, cancellationToken); + var includeItems = request.IncludeItems; + var result = new List(groups.Count); + + foreach (var group in groups) + { + IReadOnlyList items = Array.Empty(); + if (includeItems) + { + var itemEntities = await repository.GetItemsByGroupIdAsync(group.Id, cancellationToken); + items = itemEntities.Select(MapItem).ToList(); + } + + result.Add(MapGroup(group, includeItems, items)); + } + + return result; + } + + public async Task CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default) + { + var group = await RequireGroupAsync(request.GroupId, cancellationToken); + EnsureScopePermission(group.Scope); + + var item = new DictionaryItem + { + Id = 0, + TenantId = group.TenantId, + GroupId = group.Id, + Key = request.Key.Trim(), + Value = request.Value.Trim(), + Description = request.Description?.Trim(), + SortOrder = request.SortOrder, + IsDefault = request.IsDefault, + IsEnabled = request.IsEnabled + }; + + await repository.AddItemAsync(item, cancellationToken); + await repository.SaveChangesAsync(cancellationToken); + await InvalidateCacheAsync(group, cancellationToken); + logger.LogInformation("新增字典项:{ItemId}", item.Id); + return MapItem(item); + } + + public async Task UpdateItemAsync(long itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default) + { + var item = await RequireItemAsync(itemId, cancellationToken); + var group = await RequireGroupAsync(item.GroupId, cancellationToken); + EnsureScopePermission(group.Scope); + + item.Value = request.Value.Trim(); + item.Description = request.Description?.Trim(); + item.SortOrder = request.SortOrder; + item.IsDefault = request.IsDefault; + item.IsEnabled = request.IsEnabled; + + await repository.SaveChangesAsync(cancellationToken); + await InvalidateCacheAsync(group, cancellationToken); + logger.LogInformation("更新字典项:{ItemId}", item.Id); + return MapItem(item); + } + + public async Task DeleteItemAsync(long itemId, CancellationToken cancellationToken = default) + { + var item = await RequireItemAsync(itemId, cancellationToken); + var group = await RequireGroupAsync(item.GroupId, cancellationToken); + EnsureScopePermission(group.Scope); + + await repository.RemoveItemAsync(item, cancellationToken); + await repository.SaveChangesAsync(cancellationToken); + await InvalidateCacheAsync(group, cancellationToken); + logger.LogInformation("删除字典项:{ItemId}", item.Id); + } + + public async Task>> GetCachedItemsAsync(DictionaryBatchQueryRequest request, CancellationToken cancellationToken = default) + { + var normalizedCodes = request.Codes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(NormalizeCode) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (normalizedCodes.Length == 0) + { + return new Dictionary>(StringComparer.OrdinalIgnoreCase); + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var code in normalizedCodes) + { + var systemItems = await GetOrLoadCacheAsync(0, code, cancellationToken); + if (tenantId == 0) + { + result[code] = systemItems; + continue; + } + + var tenantItems = await GetOrLoadCacheAsync(tenantId, code, cancellationToken); + result[code] = MergeItems(systemItems, tenantItems); + } + + return result; + } + + private async Task RequireGroupAsync(long groupId, CancellationToken cancellationToken) + { + var group = await repository.FindGroupByIdAsync(groupId, cancellationToken); + if (group == null) + { + throw new BusinessException(ErrorCodes.NotFound, "字典分组不存在"); + } + + return group; + } + + private async Task RequireItemAsync(long itemId, CancellationToken cancellationToken) + { + var item = await repository.FindItemByIdAsync(itemId, cancellationToken); + if (item == null) + { + throw new BusinessException(ErrorCodes.NotFound, "字典项不存在"); + } + + return item; + } + + private long ResolveTargetTenant(DictionaryScope scope) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + if (scope == DictionaryScope.System) + { + EnsurePlatformTenant(tenantId); + return 0; + } + + if (tenantId == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "业务参数需指定租户"); + } + + return tenantId; + } + + private static string NormalizeCode(string code) => code.Trim().ToLowerInvariant(); + + private static DictionaryScope ResolveScopeForQuery(DictionaryScope? requestedScope, long tenantId) + { + if (requestedScope.HasValue) + { + return requestedScope.Value; + } + + return tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business; + } + + private void EnsureScopePermission(DictionaryScope scope) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + if (scope == DictionaryScope.System && tenantId != 0) + { + throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典"); + } + } + + private void EnsurePlatformTenant(long tenantId) + { + if (tenantId != 0) + { + throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典"); + } + } + + private async Task InvalidateCacheAsync(DictionaryGroup group, CancellationToken cancellationToken) + { + await cache.RemoveAsync(group.TenantId, group.Code, cancellationToken); + if (group.Scope == DictionaryScope.Business) + { + return; + } + + // 系统参数更新需要逐租户重新合并,由调用方在下一次请求时重新加载 + } + + private async Task> GetOrLoadCacheAsync(long tenantId, string code, CancellationToken cancellationToken) + { + var cached = await cache.GetAsync(tenantId, code, cancellationToken); + if (cached != null) + { + return cached; + } + + var entities = await repository.GetItemsByCodesAsync(new[] { code }, tenantId, includeSystem: false, cancellationToken); + var items = entities + .Where(item => item.IsEnabled && (item.Group?.IsEnabled ?? true)) + .Select(MapItem) + .OrderBy(item => item.SortOrder) + .ToList(); + + await cache.SetAsync(tenantId, code, items, cancellationToken); + return items; + } + + private static IReadOnlyList MergeItems(IReadOnlyList systemItems, IReadOnlyList tenantItems) + { + if (tenantItems.Count == 0) + { + return systemItems; + } + + if (systemItems.Count == 0) + { + return tenantItems; + } + + return systemItems.Concat(tenantItems) + .OrderBy(item => item.SortOrder) + .ToList(); + } + + private static DictionaryGroupDto MapGroup(DictionaryGroup group, bool includeItems, IReadOnlyList? items = null) + { + return new DictionaryGroupDto + { + Id = group.Id, + Code = group.Code, + Name = group.Name, + Scope = group.Scope, + Description = group.Description, + IsEnabled = group.IsEnabled, + Items = includeItems ? items ?? group.Items.Select(MapItem).ToList() : Array.Empty() + }; + } + + private static DictionaryItemDto MapItem(DictionaryItem item) + => new() + { + Id = item.Id, + GroupId = item.GroupId, + Key = item.Key, + Value = item.Value, + IsDefault = item.IsDefault, + IsEnabled = item.IsEnabled, + SortOrder = item.SortOrder, + Description = item.Description + }; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs new file mode 100644 index 0000000..d1cd0a7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 管理后台认证服务。 +/// +public interface IAdminAuthService +{ + Task LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default); + Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default); + Task GetProfileAsync(long userId, CancellationToken cancellationToken = default); + Task GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default); + Task> SearchUserPermissionsAsync(string? keyword, int page, int pageSize, string? sortBy, bool sortDescending, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs new file mode 100644 index 0000000..4235181 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// JWT 令牌服务契约。 +/// +public interface IJwtTokenService +{ + Task CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs new file mode 100644 index 0000000..f4a7c5c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 登录限流器。 +/// +public interface ILoginRateLimiter +{ + Task EnsureAllowedAsync(string key, CancellationToken cancellationToken = default); + Task ResetAsync(string key, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs new file mode 100644 index 0000000..c7a509e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 小程序认证服务。 +/// +public interface IMiniAuthService +{ + Task LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default); + Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default); + Task GetProfileAsync(long userId, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs new file mode 100644 index 0000000..29a1bf6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Identity.Models; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 刷新令牌存储。 +/// +public interface IRefreshTokenStore +{ + Task IssueAsync(long userId, DateTime expiresAt, CancellationToken cancellationToken = default); + Task GetAsync(string refreshToken, CancellationToken cancellationToken = default); + Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs new file mode 100644 index 0000000..417c8b9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TakeoutSaaS.Application.Identity.Abstractions; + +/// +/// 微信 code2Session 服务契约。 +/// +public interface IWeChatAuthService +{ + Task Code2SessionAsync(string code, CancellationToken cancellationToken = default); +} + +/// +/// 微信会话信息。 +/// +public sealed class WeChatSessionInfo +{ + public string OpenId { get; init; } = string.Empty; + public string? UnionId { get; init; } + public string SessionKey { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/AssignUserRolesCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/AssignUserRolesCommand.cs new file mode 100644 index 0000000..14672a3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/AssignUserRolesCommand.cs @@ -0,0 +1,12 @@ +using MediatR; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 为用户分配角色(覆盖式)。 +/// +public sealed record AssignUserRolesCommand : IRequest +{ + public long UserId { get; init; } + public long[] RoleIds { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/BindRolePermissionsCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/BindRolePermissionsCommand.cs new file mode 100644 index 0000000..aec3397 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/BindRolePermissionsCommand.cs @@ -0,0 +1,12 @@ +using MediatR; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 绑定角色权限(覆盖式)。 +/// +public sealed record BindRolePermissionsCommand : IRequest +{ + public long RoleId { get; init; } + public long[] PermissionIds { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CreatePermissionCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreatePermissionCommand.cs new file mode 100644 index 0000000..d554152 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreatePermissionCommand.cs @@ -0,0 +1,14 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 创建权限。 +/// +public sealed record CreatePermissionCommand : IRequest +{ + public string Name { get; init; } = string.Empty; + public string Code { get; init; } = string.Empty; + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleCommand.cs new file mode 100644 index 0000000..dadc2a3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleCommand.cs @@ -0,0 +1,14 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 创建角色。 +/// +public sealed record CreateRoleCommand : IRequest +{ + public string Name { get; init; } = string.Empty; + public string Code { get; init; } = string.Empty; + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/DeletePermissionCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeletePermissionCommand.cs new file mode 100644 index 0000000..ea91997 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeletePermissionCommand.cs @@ -0,0 +1,11 @@ +using MediatR; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 删除权限。 +/// +public sealed record DeletePermissionCommand : IRequest +{ + public long PermissionId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteRoleCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteRoleCommand.cs new file mode 100644 index 0000000..09085c4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteRoleCommand.cs @@ -0,0 +1,11 @@ +using MediatR; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 删除角色。 +/// +public sealed record DeleteRoleCommand : IRequest +{ + public long RoleId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdatePermissionCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdatePermissionCommand.cs new file mode 100644 index 0000000..edcb482 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdatePermissionCommand.cs @@ -0,0 +1,14 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 更新权限。 +/// +public sealed record UpdatePermissionCommand : IRequest +{ + public long PermissionId { get; init; } + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleCommand.cs new file mode 100644 index 0000000..b4d58a2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleCommand.cs @@ -0,0 +1,14 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 更新角色。 +/// +public sealed record UpdateRoleCommand : IRequest +{ + public long RoleId { get; init; } + public string Name { get; init; } = string.Empty; + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/AdminLoginRequest.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/AdminLoginRequest.cs new file mode 100644 index 0000000..fbbe58d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/AdminLoginRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 管理后台登录请求。 +/// +public sealed class AdminLoginRequest +{ + [Required] + [MaxLength(64)] + public string Account { get; set; } = string.Empty; + + [Required] + [MaxLength(128)] + public string Password { get; set; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs new file mode 100644 index 0000000..922b7d3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs @@ -0,0 +1,47 @@ +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 登录用户档案。 +/// +public sealed class CurrentUserProfile +{ + /// + /// 用户 ID。 + /// + public long UserId { get; init; } + + /// + /// 登录账号。 + /// + public string Account { get; init; } = string.Empty; + + /// + /// 展示名称。 + /// + public string DisplayName { get; init; } = string.Empty; + + /// + /// 所属租户 ID。 + /// + public long TenantId { get; init; } + + /// + /// 所属商户 ID(平台管理员为空)。 + /// + public long? MerchantId { get; init; } + + /// + /// 角色集合。 + /// + public string[] Roles { get; init; } = Array.Empty(); + + /// + /// 权限集合。 + /// + public string[] Permissions { get; init; } = Array.Empty(); + + /// + /// 头像地址(可选)。 + /// + public string? Avatar { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionDto.cs new file mode 100644 index 0000000..e9623bd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionDto.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 权限 DTO。 +/// +public sealed class PermissionDto +{ + /// + /// 权限 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 权限名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 权限编码(租户内唯一)。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 描述。 + /// + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/RefreshTokenRequest.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RefreshTokenRequest.cs new file mode 100644 index 0000000..67b3c53 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RefreshTokenRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 刷新令牌请求。 +/// +public sealed class RefreshTokenRequest +{ + [Required] + [MaxLength(256)] + public string RefreshToken { get; set; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleDto.cs new file mode 100644 index 0000000..31119d3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleDto.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 角色 DTO。 +/// +public sealed class RoleDto +{ + /// + /// 角色 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 角色名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 角色编码(租户内唯一)。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 描述。 + /// + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs new file mode 100644 index 0000000..716364e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// Access/Refresh 令牌响应。 +/// +public class TokenResponse +{ + /// + /// 访问令牌(JWT)。 + /// + public string AccessToken { get; init; } = string.Empty; + + /// + /// 访问令牌过期时间(UTC)。 + /// + public DateTime AccessTokenExpiresAt { get; init; } + + /// + /// 刷新令牌。 + /// + public string RefreshToken { get; init; } = string.Empty; + + /// + /// 刷新令牌过期时间(UTC)。 + /// + public DateTime RefreshTokenExpiresAt { get; init; } + + /// + /// 当前用户档案(可选,首次登录时可能为空)。 + /// + public CurrentUserProfile? User { get; init; } + + /// + /// 是否为新用户(首次登录)。 + /// + public bool IsNewUser { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs new file mode 100644 index 0000000..f3393ad --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs @@ -0,0 +1,54 @@ +using System; +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 用户权限概览 DTO。 +/// +public sealed class UserPermissionDto +{ + /// + /// 用户 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long UserId { get; init; } + + /// + /// 租户 ID(雪花,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 商户 ID(雪花,序列化为字符串,可空)。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? MerchantId { get; init; } + + /// + /// 登录账号。 + /// + public string Account { get; init; } = string.Empty; + + /// + /// 展示名称。 + /// + public string DisplayName { get; init; } = string.Empty; + + /// + /// 角色集合。 + /// + public string[] Roles { get; init; } = Array.Empty(); + + /// + /// 权限集合。 + /// + public string[] Permissions { get; init; } = Array.Empty(); + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/WeChatLoginRequest.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/WeChatLoginRequest.cs new file mode 100644 index 0000000..27152e6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/WeChatLoginRequest.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 微信小程序登录请求。 +/// +public sealed class WeChatLoginRequest +{ + [Required] + [MaxLength(128)] + public string Code { get; set; } = string.Empty; + + [MaxLength(64)] + public string? Nickname { get; set; } + + [MaxLength(256)] + public string? Avatar { get; set; } + + public string? EncryptedData { get; set; } + + public string? Iv { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs new file mode 100644 index 0000000..c5df667 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Extensions/IdentityServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Services; + +namespace TakeoutSaaS.Application.Identity.Extensions; + +/// +/// 应用层身份认证服务注入 +/// +public static class IdentityServiceCollectionExtensions +{ + /// + /// 注册身份认证相关应用服务 + /// + /// 服务集合 + /// 是否注册小程序认证服务 + public static IServiceCollection AddIdentityApplication(this IServiceCollection services, bool enableMiniSupport = false) + { + services.AddScoped(); + + if (enableMiniSupport) + { + services.AddScoped(); + } + + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/AssignUserRolesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/AssignUserRolesCommandHandler.cs new file mode 100644 index 0000000..8105f69 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/AssignUserRolesCommandHandler.cs @@ -0,0 +1,23 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 用户角色分配处理器。 +/// +public sealed class AssignUserRolesCommandHandler( + IUserRoleRepository userRoleRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + public async Task Handle(AssignUserRolesCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + await userRoleRepository.ReplaceUserRolesAsync(tenantId, request.UserId, request.RoleIds, cancellationToken); + await userRoleRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/BindRolePermissionsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/BindRolePermissionsCommandHandler.cs new file mode 100644 index 0000000..eee1e9e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/BindRolePermissionsCommandHandler.cs @@ -0,0 +1,23 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 绑定角色权限处理器。 +/// +public sealed class BindRolePermissionsCommandHandler( + IRolePermissionRepository rolePermissionRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + public async Task Handle(BindRolePermissionsCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + await rolePermissionRepository.ReplaceRolePermissionsAsync(tenantId, request.RoleId, request.PermissionIds, cancellationToken); + await rolePermissionRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreatePermissionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreatePermissionCommandHandler.cs new file mode 100644 index 0000000..275946e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreatePermissionCommandHandler.cs @@ -0,0 +1,41 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 创建权限处理器。 +/// +public sealed class CreatePermissionCommandHandler( + IPermissionRepository permissionRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + public async Task Handle(CreatePermissionCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var permission = new Permission + { + TenantId = tenantId, + Name = request.Name, + Code = request.Code, + Description = request.Description + }; + + await permissionRepository.AddAsync(permission, cancellationToken); + await permissionRepository.SaveChangesAsync(cancellationToken); + + return new PermissionDto + { + Id = permission.Id, + TenantId = permission.TenantId, + Name = permission.Name, + Code = permission.Code, + Description = permission.Description + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleCommandHandler.cs new file mode 100644 index 0000000..717393a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleCommandHandler.cs @@ -0,0 +1,41 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 创建角色处理器。 +/// +public sealed class CreateRoleCommandHandler( + IRoleRepository roleRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + public async Task Handle(CreateRoleCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var role = new Role + { + TenantId = tenantId, + Name = request.Name, + Code = request.Code, + Description = request.Description + }; + + await roleRepository.AddAsync(role, cancellationToken); + await roleRepository.SaveChangesAsync(cancellationToken); + + return new RoleDto + { + Id = role.Id, + TenantId = role.TenantId, + Name = role.Name, + Code = role.Code, + Description = role.Description + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeletePermissionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeletePermissionCommandHandler.cs new file mode 100644 index 0000000..9dc2ce8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeletePermissionCommandHandler.cs @@ -0,0 +1,23 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 删除权限处理器。 +/// +public sealed class DeletePermissionCommandHandler( + IPermissionRepository permissionRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + public async Task Handle(DeletePermissionCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + await permissionRepository.DeleteAsync(request.PermissionId, tenantId, cancellationToken); + await permissionRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleCommandHandler.cs new file mode 100644 index 0000000..c45241a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleCommandHandler.cs @@ -0,0 +1,23 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 删除角色处理器。 +/// +public sealed class DeleteRoleCommandHandler( + IRoleRepository roleRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + public async Task Handle(DeleteRoleCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + await roleRepository.DeleteAsync(request.RoleId, tenantId, cancellationToken); + await roleRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs new file mode 100644 index 0000000..a5a2a42 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs @@ -0,0 +1,88 @@ +using System; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 按用户 ID 获取权限概览处理器。 +/// +public sealed class GetUserPermissionsQueryHandler( + IIdentityUserRepository identityUserRepository, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + IPermissionRepository permissionRepository, + IRolePermissionRepository rolePermissionRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository; + private readonly IUserRoleRepository _userRoleRepository = userRoleRepository; + private readonly IRoleRepository _roleRepository = roleRepository; + private readonly IPermissionRepository _permissionRepository = permissionRepository; + private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task Handle(GetUserPermissionsQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var user = await _identityUserRepository.FindByIdAsync(request.UserId, cancellationToken); + if (user == null || user.TenantId != tenantId) + { + return null; + } + + var roleCodes = await ResolveUserRolesAsync(tenantId, user.Id, cancellationToken); + var permissionCodes = await ResolveUserPermissionsAsync(tenantId, user.Id, cancellationToken); + + return new UserPermissionDto + { + UserId = user.Id, + TenantId = user.TenantId, + MerchantId = user.MerchantId, + Account = user.Account, + DisplayName = user.DisplayName, + Roles = roleCodes, + Permissions = permissionCodes, + CreatedAt = user.CreatedAt + }; + } + + private async Task ResolveUserRolesAsync(long tenantId, long userId, CancellationToken cancellationToken) + { + var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); + var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray(); + if (roleIds.Length == 0) + { + return Array.Empty(); + } + + var roles = await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); + return roles.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private async Task ResolveUserPermissionsAsync(long tenantId, long userId, CancellationToken cancellationToken) + { + var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); + var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray(); + if (roleIds.Length == 0) + { + return Array.Empty(); + } + + var rolePermissions = await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken); + var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray(); + if (permissionIds.Length == 0) + { + return Array.Empty(); + } + + var permissions = await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken); + return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs new file mode 100644 index 0000000..97bdd1b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs @@ -0,0 +1,54 @@ +using System; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 权限分页查询处理器。 +/// +public sealed class SearchPermissionsQueryHandler( + IPermissionRepository permissionRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + public async Task> Handle(SearchPermissionsQuery request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var permissions = await permissionRepository.SearchAsync(tenantId, request.Keyword, cancellationToken); + + var sorted = request.SortBy?.ToLowerInvariant() switch + { + "name" => request.SortDescending + ? permissions.OrderByDescending(x => x.Name) + : permissions.OrderBy(x => x.Name), + "code" => request.SortDescending + ? permissions.OrderByDescending(x => x.Code) + : permissions.OrderBy(x => x.Code), + _ => request.SortDescending + ? permissions.OrderByDescending(x => x.CreatedAt) + : permissions.OrderBy(x => x.CreatedAt) + }; + + var paged = sorted + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .ToList(); + + var items = paged.Select(permission => new PermissionDto + { + Id = permission.Id, + TenantId = permission.TenantId, + Name = permission.Name, + Code = permission.Code, + Description = permission.Description + }).ToList(); + + return new PagedResult(items, request.Page, request.PageSize, permissions.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs new file mode 100644 index 0000000..bd11a5d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 角色分页查询处理器。 +/// +public sealed class SearchRolesQueryHandler( + IRoleRepository roleRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + public async Task> Handle(SearchRolesQuery request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var roles = await roleRepository.SearchAsync(tenantId, request.Keyword, cancellationToken); + + var sorted = request.SortBy?.ToLowerInvariant() switch + { + "name" => request.SortDescending + ? roles.OrderByDescending(x => x.Name) + : roles.OrderBy(x => x.Name), + _ => request.SortDescending + ? roles.OrderByDescending(x => x.CreatedAt) + : roles.OrderBy(x => x.CreatedAt) + }; + + var paged = sorted + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .ToList(); + + var items = paged.Select(role => new RoleDto + { + Id = role.Id, + TenantId = role.TenantId, + Name = role.Name, + Code = role.Code, + Description = role.Description + }).ToList(); + + return new PagedResult(items, request.Page, request.PageSize, roles.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs new file mode 100644 index 0000000..07e3595 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 租户用户权限分页查询处理器。 +/// +public sealed class SearchUserPermissionsQueryHandler( + IIdentityUserRepository identityUserRepository, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + IPermissionRepository permissionRepository, + IRolePermissionRepository rolePermissionRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository; + private readonly IUserRoleRepository _userRoleRepository = userRoleRepository; + private readonly IRoleRepository _roleRepository = roleRepository; + private readonly IPermissionRepository _permissionRepository = permissionRepository; + private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(SearchUserPermissionsQuery request, CancellationToken cancellationToken) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var users = await _identityUserRepository.SearchAsync(tenantId, request.Keyword, cancellationToken); + + var sorted = SortUsers(users, request.SortBy, request.SortDescending); + var paged = sorted + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .ToList(); + + var resolved = await ResolveRolesAndPermissionsAsync(tenantId, paged, cancellationToken); + var items = paged.Select(user => new UserPermissionDto + { + UserId = user.Id, + TenantId = user.TenantId, + MerchantId = user.MerchantId, + Account = user.Account, + DisplayName = user.DisplayName, + Roles = resolved[user.Id].roles, + Permissions = resolved[user.Id].permissions, + CreatedAt = user.CreatedAt + }).ToList(); + + return new PagedResult(items, request.Page, request.PageSize, users.Count); + } + + private static IOrderedEnumerable SortUsers( + IReadOnlyCollection users, + string? sortBy, + bool sortDescending) + { + return sortBy?.ToLowerInvariant() switch + { + "account" => sortDescending + ? users.OrderByDescending(x => x.Account) + : users.OrderBy(x => x.Account), + "displayname" => sortDescending + ? users.OrderByDescending(x => x.DisplayName) + : users.OrderBy(x => x.DisplayName), + _ => sortDescending + ? users.OrderByDescending(x => x.CreatedAt) + : users.OrderBy(x => x.CreatedAt) + }; + } + + private async Task> ResolveRolesAndPermissionsAsync( + long tenantId, + IReadOnlyCollection users, + CancellationToken cancellationToken) + { + var userIds = users.Select(x => x.Id).ToArray(); + var userRoleRelations = await _userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken); + var roleIds = userRoleRelations.Select(x => x.RoleId).Distinct().ToArray(); + + var roles = roleIds.Length == 0 + ? Array.Empty() + : await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); + var roleCodeMap = roles.ToDictionary(r => r.Id, r => r.Code, comparer: EqualityComparer.Default); + + var rolePermissions = roleIds.Length == 0 + ? Array.Empty() + : await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken); + var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray(); + + var permissions = permissionIds.Length == 0 + ? Array.Empty() + : await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken); + var permissionCodeMap = permissions.ToDictionary(p => p.Id, p => p.Code, comparer: EqualityComparer.Default); + + var rolePermissionsLookup = rolePermissions + .GroupBy(rp => rp.RoleId) + .ToDictionary(g => g.Key, g => g.Select(rp => rp.PermissionId).ToArray(), comparer: EqualityComparer.Default); + + var result = new Dictionary(); + foreach (var userId in userIds) + { + var rolesForUser = userRoleRelations.Where(ur => ur.UserId == userId).Select(ur => ur.RoleId).Distinct().ToArray(); + var roleCodes = rolesForUser + .Select(rid => roleCodeMap.GetValueOrDefault(rid)) + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Select(c => c!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var permissionCodes = rolesForUser + .SelectMany(rid => rolePermissionsLookup.GetValueOrDefault(rid) ?? Array.Empty()) + .Select(pid => permissionCodeMap.GetValueOrDefault(pid)) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + result[userId] = (roleCodes, permissionCodes); + } + + return result; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdatePermissionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdatePermissionCommandHandler.cs new file mode 100644 index 0000000..b123164 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdatePermissionCommandHandler.cs @@ -0,0 +1,41 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 更新权限处理器。 +/// +public sealed class UpdatePermissionCommandHandler( + IPermissionRepository permissionRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + public async Task Handle(UpdatePermissionCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var permission = await permissionRepository.FindByIdAsync(request.PermissionId, tenantId, cancellationToken); + if (permission == null) + { + return null; + } + + permission.Name = request.Name; + permission.Description = request.Description; + + await permissionRepository.UpdateAsync(permission, cancellationToken); + await permissionRepository.SaveChangesAsync(cancellationToken); + + return new PermissionDto + { + Id = permission.Id, + TenantId = permission.TenantId, + Name = permission.Name, + Code = permission.Code, + Description = permission.Description + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleCommandHandler.cs new file mode 100644 index 0000000..c9b6a2d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleCommandHandler.cs @@ -0,0 +1,41 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 更新角色处理器。 +/// +public sealed class UpdateRoleCommandHandler( + IRoleRepository roleRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + public async Task Handle(UpdateRoleCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var role = await roleRepository.FindByIdAsync(request.RoleId, tenantId, cancellationToken); + if (role == null) + { + return null; + } + + role.Name = request.Name; + role.Description = request.Description; + + await roleRepository.UpdateAsync(role, cancellationToken); + await roleRepository.SaveChangesAsync(cancellationToken); + + return new RoleDto + { + Id = role.Id, + TenantId = role.TenantId, + Name = role.Name, + Code = role.Code, + Description = role.Description + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs b/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs new file mode 100644 index 0000000..8a968bb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs @@ -0,0 +1,14 @@ +namespace TakeoutSaaS.Application.Identity.Models; + +/// +/// 刷新令牌描述:存储刷新令牌的元数据信息。 +/// +/// 刷新令牌值 +/// 关联的用户 ID +/// 过期时间(UTC) +/// 是否已撤销 +public sealed record RefreshTokenDescriptor( + string Token, + long UserId, + DateTime ExpiresAt, + bool Revoked); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/GetUserPermissionsQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetUserPermissionsQuery.cs new file mode 100644 index 0000000..84b11d2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetUserPermissionsQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 按用户 ID 获取角色/权限概览。 +/// +public sealed class GetUserPermissionsQuery : IRequest +{ + /// + /// 用户 ID(雪花)。 + /// + public long UserId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchPermissionsQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchPermissionsQuery.cs new file mode 100644 index 0000000..8547d6e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchPermissionsQuery.cs @@ -0,0 +1,17 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 分页查询权限。 +/// +public sealed class SearchPermissionsQuery : IRequest> +{ + public string? Keyword { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 20; + public string? SortBy { get; init; } + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchRolesQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchRolesQuery.cs new file mode 100644 index 0000000..c4160a2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchRolesQuery.cs @@ -0,0 +1,17 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 分页查询角色。 +/// +public sealed class SearchRolesQuery : IRequest> +{ + public string? Keyword { get; init; } + public int Page { get; init; } = 1; + public int PageSize { get; init; } = 20; + public string? SortBy { get; init; } + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchUserPermissionsQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchUserPermissionsQuery.cs new file mode 100644 index 0000000..1a6d557 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchUserPermissionsQuery.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 按租户分页查询用户的角色/权限概览。 +/// +public sealed class SearchUserPermissionsQuery : IRequest> +{ + /// + /// 关键字(账号或展示名称)。 + /// + public string? Keyword { get; init; } + + /// + /// 页码,从 1 开始。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段(account/displayName/createdAt)。 + /// + public string? SortBy { get; init; } + + /// + /// 是否倒序。 + /// + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs new file mode 100644 index 0000000..4836eb8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Identity; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Services; + +/// +/// 管理后台认证服务实现。 +/// +public sealed class AdminAuthService( + IIdentityUserRepository userRepository, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + IPermissionRepository permissionRepository, + IRolePermissionRepository rolePermissionRepository, + IPasswordHasher passwordHasher, + IJwtTokenService jwtTokenService, + IRefreshTokenStore refreshTokenStore, + ITenantProvider tenantProvider) : IAdminAuthService +{ + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly IUserRoleRepository _userRoleRepository = userRoleRepository; + private readonly IRoleRepository _roleRepository = roleRepository; + private readonly IPermissionRepository _permissionRepository = permissionRepository; + private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository; + + /// + /// 管理后台登录:验证账号密码并生成令牌。 + /// + /// 登录请求 + /// 取消令牌 + /// 令牌响应 + /// 账号或密码错误时抛出 + public async Task LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default) + { + // 1. 根据账号查找用户 + var user = await userRepository.FindByAccountAsync(request.Account, cancellationToken) + ?? throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误"); + + // 2. 验证密码(使用 ASP.NET Core Identity 的密码哈希器) + var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password); + if (result == PasswordVerificationResult.Failed) + { + throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误"); + } + + // 3. 构建用户档案并生成令牌 + var profile = await BuildProfileAsync(user, cancellationToken); + return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken); + } + + /// + /// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。 + /// + /// 刷新令牌请求 + /// 取消令牌 + /// 新的令牌响应 + /// 刷新令牌无效、已过期或用户不存在时抛出 + public async Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default) + { + // 1. 验证刷新令牌(检查是否存在、是否过期、是否已撤销) + var descriptor = await refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken); + if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked) + { + throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期"); + } + + // 2. 根据用户 ID 查找用户 + var user = await userRepository.FindByIdAsync(descriptor.UserId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在"); + + // 3. 撤销旧刷新令牌(防止重复使用) + await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken); + + // 4. 生成新的令牌对 + var profile = await BuildProfileAsync(user, cancellationToken); + return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken); + } + + /// + /// 获取用户档案。 + /// + /// 用户 ID + /// 取消令牌 + /// 用户档案 + /// 用户不存在时抛出 + public async Task GetProfileAsync(long userId, CancellationToken cancellationToken = default) + { + var user = await userRepository.FindByIdAsync(userId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在"); + + return await BuildProfileAsync(user, cancellationToken); + } + + /// + /// 获取指定用户的权限概览(校验当前租户)。 + /// + public async Task GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var user = await userRepository.FindByIdAsync(userId, cancellationToken); + if (user == null || user.TenantId != tenantId) + { + return null; + } + + var roleCodes = await ResolveUserRolesAsync(tenantId, user.Id, cancellationToken); + var permissionCodes = await ResolveUserPermissionsAsync(tenantId, user.Id, cancellationToken); + + return new UserPermissionDto + { + UserId = user.Id, + TenantId = user.TenantId, + MerchantId = user.MerchantId, + Account = user.Account, + DisplayName = user.DisplayName, + Roles = roleCodes, + Permissions = permissionCodes, + CreatedAt = user.CreatedAt + }; + } + + /// + /// 按租户分页查询用户权限概览。 + /// + public async Task> SearchUserPermissionsAsync( + string? keyword, + int page, + int pageSize, + string? sortBy, + bool sortDescending, + CancellationToken cancellationToken = default) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var users = await userRepository.SearchAsync(tenantId, keyword, cancellationToken); + + var sorted = sortBy?.ToLowerInvariant() switch + { + "account" => sortDescending + ? users.OrderByDescending(x => x.Account) + : users.OrderBy(x => x.Account), + "displayname" => sortDescending + ? users.OrderByDescending(x => x.DisplayName) + : users.OrderBy(x => x.DisplayName), + _ => sortDescending + ? users.OrderByDescending(x => x.CreatedAt) + : users.OrderBy(x => x.CreatedAt) + }; + + var paged = sorted + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToList(); + + var resolved = await ResolveRolesAndPermissionsAsync(tenantId, paged, cancellationToken); + var items = paged.Select(user => new UserPermissionDto + { + UserId = user.Id, + TenantId = user.TenantId, + MerchantId = user.MerchantId, + Account = user.Account, + DisplayName = user.DisplayName, + Roles = resolved[user.Id].roles, + Permissions = resolved[user.Id].permissions, + CreatedAt = user.CreatedAt + }).ToList(); + + return new PagedResult(items, page, pageSize, users.Count); + } + + private async Task BuildProfileAsync(IdentityUser user, CancellationToken cancellationToken) + { + var tenantId = user.TenantId; + var roles = await ResolveUserRolesAsync(tenantId, user.Id, cancellationToken); + var permissions = await ResolveUserPermissionsAsync(tenantId, user.Id, cancellationToken); + + return new CurrentUserProfile + { + UserId = user.Id, + Account = user.Account, + DisplayName = user.DisplayName, + TenantId = user.TenantId, + MerchantId = user.MerchantId, + Roles = roles, + Permissions = permissions, + Avatar = user.Avatar + }; + } + + private async Task ResolveUserRolesAsync(long tenantId, long userId, CancellationToken cancellationToken) + { + var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); + var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray(); + if (roleIds.Length == 0) + { + return Array.Empty(); + } + + var roles = await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); + return roles.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private async Task ResolveUserPermissionsAsync(long tenantId, long userId, CancellationToken cancellationToken) + { + var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); + var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray(); + if (roleIds.Length == 0) + { + return Array.Empty(); + } + + var rolePermissions = await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken); + var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray(); + if (permissionIds.Length == 0) + { + return Array.Empty(); + } + + var permissions = await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken); + return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private async Task> ResolveRolesAndPermissionsAsync( + long tenantId, + IReadOnlyCollection users, + CancellationToken cancellationToken) + { + var userIds = users.Select(x => x.Id).ToArray(); + var userRoleRelations = await _userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken); + var roleIds = userRoleRelations.Select(x => x.RoleId).Distinct().ToArray(); + + var roles = roleIds.Length == 0 + ? Array.Empty() + : await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); + var roleCodeMap = roles.ToDictionary(r => r.Id, r => r.Code, comparer: EqualityComparer.Default); + + var rolePermissions = roleIds.Length == 0 + ? Array.Empty() + : await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken); + + var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray(); + var permissions = permissionIds.Length == 0 + ? Array.Empty() + : await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken); + var permissionCodeMap = permissions.ToDictionary(p => p.Id, p => p.Code, comparer: EqualityComparer.Default); + + var rolePermissionsLookup = rolePermissions + .GroupBy(rp => rp.RoleId) + .ToDictionary(g => g.Key, g => g.Select(rp => rp.PermissionId).ToArray(), comparer: EqualityComparer.Default); + + var result = new Dictionary(); + foreach (var userId in userIds) + { + var rolesForUser = userRoleRelations.Where(ur => ur.UserId == userId).Select(ur => ur.RoleId).Distinct().ToArray(); + var roleCodes = rolesForUser + .Select(rid => roleCodeMap.GetValueOrDefault(rid)) + .Where(c => !string.IsNullOrWhiteSpace(c)) + .Select(c => c!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var permissionCodes = rolesForUser + .SelectMany(rid => rolePermissionsLookup.GetValueOrDefault(rid) ?? Array.Empty()) + .Select(pid => permissionCodeMap.GetValueOrDefault(pid)) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + result[userId] = (roleCodes, permissionCodes); + } + + return result; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs new file mode 100644 index 0000000..5d83289 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs @@ -0,0 +1,148 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Services; + +/// +/// 小程序认证服务实现。 +/// +public sealed class MiniAuthService( + IWeChatAuthService weChatAuthService, + IMiniUserRepository miniUserRepository, + IJwtTokenService jwtTokenService, + IRefreshTokenStore refreshTokenStore, + ILoginRateLimiter rateLimiter, + IHttpContextAccessor httpContextAccessor, + ITenantProvider tenantProvider) : IMiniAuthService +{ + /// + /// 微信小程序登录:通过微信 code 获取用户信息并生成令牌。 + /// + /// 微信登录请求 + /// 取消令牌 + /// 令牌响应 + /// 获取微信用户信息失败、缺少租户标识时抛出 + public async Task LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default) + { + // 1. 限流检查(基于 IP 地址) + var throttleKey = BuildThrottleKey(); + await rateLimiter.EnsureAllowedAsync(throttleKey, cancellationToken); + + // 2. 通过微信 code 获取 session(OpenId、UnionId、SessionKey) + var session = await weChatAuthService.Code2SessionAsync(request.Code, cancellationToken); + if (string.IsNullOrWhiteSpace(session.OpenId)) + { + throw new BusinessException(ErrorCodes.Unauthorized, "获取微信用户信息失败"); + } + + // 3. 获取当前租户 ID(多租户支持) + var tenantId = tenantProvider.GetCurrentTenantId(); + if (tenantId == 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识"); + } + + // 4. 获取或创建小程序用户(如果 OpenId 已存在则返回现有用户,否则创建新用户) + var (user, isNew) = await GetOrBindMiniUserAsync(session.OpenId, session.UnionId, request.Nickname, request.Avatar, tenantId, cancellationToken); + + // 5. 登录成功后重置限流计数 + await rateLimiter.ResetAsync(throttleKey, cancellationToken); + + // 6. 构建用户档案并生成令牌 + var profile = BuildProfile(user); + return await jwtTokenService.CreateTokensAsync(profile, isNew, cancellationToken); + } + + /// + /// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。 + /// + /// 刷新令牌请求 + /// 取消令牌 + /// 新的令牌响应 + /// 刷新令牌无效、已过期或用户不存在时抛出 + public async Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default) + { + // 1. 验证刷新令牌(检查是否存在、是否过期、是否已撤销) + var descriptor = await refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken); + if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked) + { + throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期"); + } + + // 2. 根据用户 ID 查找用户 + var user = await miniUserRepository.FindByIdAsync(descriptor.UserId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在"); + + // 3. 撤销旧刷新令牌(防止重复使用) + await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken); + + // 4. 生成新的令牌对 + var profile = BuildProfile(user); + return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken); + } + + /// + /// 获取用户档案。 + /// + /// 用户 ID + /// 取消令牌 + /// 用户档案 + /// 用户不存在时抛出 + public async Task GetProfileAsync(long userId, CancellationToken cancellationToken = default) + { + var user = await miniUserRepository.FindByIdAsync(userId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在"); + + return BuildProfile(user); + } + + /// + /// 获取或绑定小程序用户:如果 OpenId 已存在则返回现有用户,否则创建新用户。 + /// + /// 微信 OpenId + /// 微信 UnionId(可选) + /// 昵称 + /// 头像地址(可选) + /// 租户 ID + /// 取消令牌 + /// 用户实体和是否为新用户的元组 + private async Task<(MiniUser user, bool isNew)> GetOrBindMiniUserAsync(string openId, string? unionId, string? nickname, string? avatar, long tenantId, CancellationToken cancellationToken) + { + // 检查用户是否已存在 + var existing = await miniUserRepository.FindByOpenIdAsync(openId, cancellationToken); + if (existing != null) + { + return (existing, false); + } + + // 创建新用户 + var created = await miniUserRepository.CreateOrUpdateAsync(openId, unionId, nickname, avatar, tenantId, cancellationToken); + return (created, true); + } + + private static CurrentUserProfile BuildProfile(MiniUser user) + => new() + { + UserId = user.Id, + Account = user.OpenId, + DisplayName = user.Nickname, + TenantId = user.TenantId, + MerchantId = null, + Roles = Array.Empty(), + Permissions = Array.Empty(), + Avatar = user.Avatar + }; + + private string BuildThrottleKey() + { + var ip = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress ?? IPAddress.Loopback; + return $"mini-login:{ip}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs b/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs new file mode 100644 index 0000000..b471f0a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TakeoutSaaS.Application.Messaging.Abstractions; + +/// +/// 领域事件发布抽象。 +/// +public interface IEventPublisher +{ + /// + /// 发布领域事件。 + /// + Task PublishAsync(string routingKey, TEvent @event, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/EventRoutingKeys.cs b/src/Application/TakeoutSaaS.Application/Messaging/EventRoutingKeys.cs new file mode 100644 index 0000000..c161ef3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Messaging/EventRoutingKeys.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Application.Messaging; + +/// +/// 事件路由键常量。 +/// +public static class EventRoutingKeys +{ + /// + /// 订单创建事件路由键。 + /// + public const string OrderCreated = "orders.created"; + + /// + /// 支付成功事件路由键。 + /// + public const string PaymentSucceeded = "payments.succeeded"; +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Events/OrderCreatedEvent.cs b/src/Application/TakeoutSaaS.Application/Messaging/Events/OrderCreatedEvent.cs new file mode 100644 index 0000000..2a84f05 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Messaging/Events/OrderCreatedEvent.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.Messaging.Events; + +/// +/// 订单创建事件。 +/// +public sealed class OrderCreatedEvent +{ + /// + /// 订单标识。 + /// + public long OrderId { get; init; } + + /// + /// 订单编号。 + /// + public string OrderNo { get; init; } = string.Empty; + + /// + /// 实付金额。 + /// + public decimal Amount { get; init; } + + /// + /// 所属租户。 + /// + public long TenantId { get; init; } + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Events/PaymentSucceededEvent.cs b/src/Application/TakeoutSaaS.Application/Messaging/Events/PaymentSucceededEvent.cs new file mode 100644 index 0000000..b0094f7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Messaging/Events/PaymentSucceededEvent.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.Messaging.Events; + +/// +/// 支付成功事件。 +/// +public sealed class PaymentSucceededEvent +{ + /// + /// 订单标识。 + /// + public long OrderId { get; init; } + + /// + /// 支付流水号。 + /// + public string PaymentNo { get; init; } = string.Empty; + + /// + /// 支付金额。 + /// + public decimal Amount { get; init; } + + /// + /// 所属租户。 + /// + public long TenantId { get; init; } + + /// + /// 支付时间(UTC)。 + /// + public DateTime PaidAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Extensions/MessagingServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Messaging/Extensions/MessagingServiceCollectionExtensions.cs new file mode 100644 index 0000000..82e9614 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Messaging/Extensions/MessagingServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Messaging.Abstractions; +using TakeoutSaaS.Application.Messaging.Services; + +namespace TakeoutSaaS.Application.Messaging.Extensions; + +/// +/// 消息模块应用层注册。 +/// +public static class MessagingServiceCollectionExtensions +{ + /// + /// 注册事件发布器。 + /// + public static IServiceCollection AddMessagingApplication(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs b/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs new file mode 100644 index 0000000..60b04d3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs @@ -0,0 +1,16 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Messaging.Abstractions; +using TakeoutSaaS.Module.Messaging.Abstractions; + +namespace TakeoutSaaS.Application.Messaging.Services; + +/// +/// 事件发布适配器,封装应用层到 MQ 的发布。 +/// +public sealed class EventPublisher(IMessagePublisher messagePublisher) : IEventPublisher +{ + /// + public Task PublishAsync(string routingKey, TEvent @event, CancellationToken cancellationToken = default) + => messagePublisher.PublishAsync(routingKey, @event, cancellationToken); +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs b/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs new file mode 100644 index 0000000..514b843 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Sms.Contracts; + +namespace TakeoutSaaS.Application.Sms.Abstractions; + +/// +/// 短信验证码服务抽象。 +/// +public interface IVerificationCodeService +{ + /// + /// 发送验证码。 + /// + Task SendAsync(SendVerificationCodeRequest request, CancellationToken cancellationToken = default); + + /// + /// 校验验证码。 + /// + Task VerifyAsync(VerifyVerificationCodeRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs new file mode 100644 index 0000000..402385f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs @@ -0,0 +1,29 @@ +using TakeoutSaaS.Module.Sms; + +namespace TakeoutSaaS.Application.Sms.Contracts; + +/// +/// 发送验证码请求。 +/// +/// +/// 创建发送请求。 +/// +public sealed class SendVerificationCodeRequest(string phoneNumber, string scene, SmsProviderKind? provider = null) +{ + + + /// + /// 手机号(支持 +86 前缀或纯 11 位)。 + /// + public string PhoneNumber { get; } = phoneNumber; + + /// + /// 业务场景(如 login/register/reset)。 + /// + public string Scene { get; } = scene; + + /// + /// 指定服务商,未指定则使用默认配置。 + /// + public SmsProviderKind? Provider { get; } = provider; +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs new file mode 100644 index 0000000..5b6cf77 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs @@ -0,0 +1,19 @@ +using System; + +namespace TakeoutSaaS.Application.Sms.Contracts; + +/// +/// 发送验证码响应。 +/// +public sealed class SendVerificationCodeResponse +{ + /// + /// 过期时间。 + /// + public DateTimeOffset ExpiresAt { get; set; } + + /// + /// 请求标识。 + /// + public string? RequestId { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs b/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs new file mode 100644 index 0000000..034230c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Application.Sms.Contracts; + +/// +/// 校验验证码请求。 +/// +/// +/// 创建校验请求。 +/// +public sealed class VerifyVerificationCodeRequest(string phoneNumber, string scene, string code) +{ + + + /// + /// 手机号。 + /// + public string PhoneNumber { get; } = phoneNumber; + + /// + /// 业务场景。 + /// + public string Scene { get; } = scene; + + /// + /// 填写的验证码。 + /// + public string Code { get; } = code; +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Extensions/SmsServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Sms/Extensions/SmsServiceCollectionExtensions.cs new file mode 100644 index 0000000..5a4d7c7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Extensions/SmsServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Sms.Abstractions; +using TakeoutSaaS.Application.Sms.Options; +using TakeoutSaaS.Application.Sms.Services; + +namespace TakeoutSaaS.Application.Sms.Extensions; + +/// +/// 短信应用服务注册扩展。 +/// +public static class SmsServiceCollectionExtensions +{ + /// + /// 注册短信验证码应用服务。 + /// + public static IServiceCollection AddSmsApplication(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("Sms:VerificationCode")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddScoped(); + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Options/VerificationCodeOptions.cs b/src/Application/TakeoutSaaS.Application/Sms/Options/VerificationCodeOptions.cs new file mode 100644 index 0000000..fd49271 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Options/VerificationCodeOptions.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.Sms.Options; + +/// +/// 验证码发送配置。 +/// +public sealed class VerificationCodeOptions +{ + /// + /// 验证码位数,默认 6。 + /// + [Range(4, 10)] + public int CodeLength { get; set; } = 6; + + /// + /// 过期时间(分钟)。 + /// + [Range(1, 60)] + public int ExpireMinutes { get; set; } = 5; + + /// + /// 发送冷却时间(秒),用于防止频繁请求。 + /// + [Range(10, 300)] + public int CooldownSeconds { get; set; } = 60; + + /// + /// 缓存前缀。 + /// + [Required] + public string CachePrefix { get; set; } = "sms:code"; +} diff --git a/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs b/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs new file mode 100644 index 0000000..88806c9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs @@ -0,0 +1,148 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Application.Sms.Abstractions; +using TakeoutSaaS.Application.Sms.Contracts; +using TakeoutSaaS.Application.Sms.Options; +using TakeoutSaaS.Module.Sms.Abstractions; +using TakeoutSaaS.Module.Sms.Models; +using TakeoutSaaS.Module.Sms.Options; +using TakeoutSaaS.Module.Sms; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Sms.Services; + +/// +/// 短信验证码服务实现。 +/// +public sealed class VerificationCodeService( + ISmsSenderResolver senderResolver, + IOptionsMonitor smsOptionsMonitor, + IOptionsMonitor codeOptionsMonitor, + ITenantProvider tenantProvider, + IDistributedCache cache, + ILogger logger) : IVerificationCodeService +{ + /// + public async Task SendAsync(SendVerificationCodeRequest request, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request.PhoneNumber)) + { + throw new BusinessException(ErrorCodes.BadRequest, "手机号不能为空"); + } + + if (string.IsNullOrWhiteSpace(request.Scene)) + { + throw new BusinessException(ErrorCodes.BadRequest, "场景不能为空"); + } + + var smsOptions = smsOptionsMonitor.CurrentValue; + var codeOptions = codeOptionsMonitor.CurrentValue; + var templateCode = ResolveTemplate(request.Scene, smsOptions); + var phone = NormalizePhoneNumber(request.PhoneNumber); + var tenantKey = tenantProvider.GetCurrentTenantId() == 0 ? "platform" : tenantProvider.GetCurrentTenantId().ToString(); + var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}"; + var cooldownKey = $"{cacheKey}:cooldown"; + + await EnsureCooldownAsync(cooldownKey, codeOptions.CooldownSeconds, cancellationToken).ConfigureAwait(false); + + var code = GenerateCode(codeOptions.CodeLength); + var variables = new Dictionary { { "code", code } }; + var sender = senderResolver.Resolve(request.Provider); + + var smsRequest = new SmsSendRequest(phone, templateCode, variables, smsOptions.DefaultSignName); + var smsResult = await sender.SendAsync(smsRequest, cancellationToken).ConfigureAwait(false); + if (!smsResult.Success) + { + throw new BusinessException(ErrorCodes.InternalServerError, $"短信发送失败:{smsResult.Message}"); + } + + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(codeOptions.ExpireMinutes); + await cache.SetStringAsync(cacheKey, code, new DistributedCacheEntryOptions + { + AbsoluteExpiration = expiresAt + }, cancellationToken).ConfigureAwait(false); + + await cache.SetStringAsync(cooldownKey, "1", new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(codeOptions.CooldownSeconds) + }, cancellationToken).ConfigureAwait(false); + + logger.LogInformation("发送验证码成功,Phone:{Phone} Scene:{Scene} Tenant:{Tenant}", phone, request.Scene, tenantKey); + return new SendVerificationCodeResponse + { + ExpiresAt = expiresAt, + RequestId = smsResult.RequestId + }; + } + + /// + public async Task VerifyAsync(VerifyVerificationCodeRequest request, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request.Code)) + { + return false; + } + + var codeOptions = codeOptionsMonitor.CurrentValue; + var phone = NormalizePhoneNumber(request.PhoneNumber); + var tenantKey = tenantProvider.GetCurrentTenantId() == 0 ? "platform" : tenantProvider.GetCurrentTenantId().ToString(); + var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}"; + + var cachedCode = await cache.GetStringAsync(cacheKey, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(cachedCode)) + { + return false; + } + + var success = string.Equals(cachedCode, request.Code, StringComparison.Ordinal); + if (success) + { + await cache.RemoveAsync(cacheKey, cancellationToken).ConfigureAwait(false); + } + + return success; + } + + private static string ResolveTemplate(string scene, SmsOptions options) + { + if (options.SceneTemplates.TryGetValue(scene, out var template) && !string.IsNullOrWhiteSpace(template)) + { + return template; + } + + throw new BusinessException(ErrorCodes.BadRequest, $"未配置场景 {scene} 的短信模板"); + } + + private static string NormalizePhoneNumber(string phone) + { + var trimmed = phone.Trim(); + return trimmed.StartsWith("+", StringComparison.Ordinal) ? trimmed : $"+86{trimmed}"; + } + + private static string GenerateCode(int length) + { + var buffer = new byte[length]; + RandomNumberGenerator.Fill(buffer); + var builder = new StringBuilder(length); + foreach (var b in buffer) + { + builder.Append((b % 10).ToString()); + } + + return builder.ToString()[..length]; + } + + private async Task EnsureCooldownAsync(string cooldownKey, int cooldownSeconds, CancellationToken cancellationToken) + { + var existing = await cache.GetStringAsync(cooldownKey, cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(existing)) + { + throw new BusinessException(ErrorCodes.BadRequest, "请求过于频繁,请稍后再试"); + } + } +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs b/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs new file mode 100644 index 0000000..f164c5c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Application.Storage.Contracts; + +namespace TakeoutSaaS.Application.Storage.Abstractions; + +/// +/// 文件存储应用服务抽象。 +/// +public interface IFileStorageService +{ + /// + /// 通过服务端中转上传文件。 + /// + Task UploadAsync(UploadFileRequest request, CancellationToken cancellationToken = default); + + /// + /// 生成前端直传凭证(预签名上传)。 + /// + Task CreateDirectUploadAsync(DirectUploadRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs new file mode 100644 index 0000000..b757e36 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs @@ -0,0 +1,39 @@ +using TakeoutSaaS.Application.Storage.Enums; + +namespace TakeoutSaaS.Application.Storage.Contracts; + +/// +/// 直传凭证请求模型。 +/// +/// +/// 创建直传请求。 +/// +public sealed class DirectUploadRequest(UploadFileType fileType, string fileName, string contentType, long contentLength, string? requestOrigin) +{ + + + /// + /// 文件类型。 + /// + public UploadFileType FileType { get; } = fileType; + + /// + /// 文件名。 + /// + public string FileName { get; } = fileName; + + /// + /// 内容类型。 + /// + public string ContentType { get; } = contentType; + + /// + /// 文件长度。 + /// + public long ContentLength { get; } = contentLength; + + /// + /// 请求来源(Origin/Referer)。 + /// + public string? RequestOrigin { get; } = requestOrigin; +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs new file mode 100644 index 0000000..4989657 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace TakeoutSaaS.Application.Storage.Contracts; + +/// +/// 直传凭证响应模型。 +/// +public sealed class DirectUploadResponse +{ + /// + /// 预签名上传地址。 + /// + public string UploadUrl { get; set; } = string.Empty; + + /// + /// 表单直传所需字段(PUT 直传为空)。 + /// + public IReadOnlyDictionary FormFields { get; set; } = new Dictionary(); + + /// + /// 预签名过期时间。 + /// + public DateTimeOffset ExpiresAt { get; set; } + + /// + /// 对象键。 + /// + public string ObjectKey { get; set; } = string.Empty; + + /// + /// 直传完成后的访问链接(包含签名)。 + /// + public string? DownloadUrl { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/FileUploadResponse.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/FileUploadResponse.cs new file mode 100644 index 0000000..3f12168 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/FileUploadResponse.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.Storage.Contracts; + +/// +/// 上传完成后的返回模型。 +/// +public sealed class FileUploadResponse +{ + /// + /// 访问 URL(已包含签名)。 + /// + public string Url { get; set; } = string.Empty; + + /// + /// 文件名。 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// 文件大小。 + /// + public long FileSize { get; set; } +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs new file mode 100644 index 0000000..ea19f91 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs @@ -0,0 +1,51 @@ +using System.IO; +using TakeoutSaaS.Application.Storage.Enums; + +namespace TakeoutSaaS.Application.Storage.Contracts; + +/// +/// 上传文件请求模型。 +/// +/// +/// 创建上传文件请求。 +/// +public sealed class UploadFileRequest( + UploadFileType fileType, + Stream content, + string fileName, + string contentType, + long contentLength, + string? requestOrigin) +{ + + + /// + /// 文件分类。 + /// + public UploadFileType FileType { get; } = fileType; + + /// + /// 文件流。 + /// + public Stream Content { get; } = content; + + /// + /// 原始文件名。 + /// + public string FileName { get; } = fileName; + + /// + /// 内容类型。 + /// + public string ContentType { get; } = contentType; + + /// + /// 文件大小。 + /// + public long ContentLength { get; } = contentLength; + + /// + /// 请求来源(Origin/Referer)。 + /// + public string? RequestOrigin { get; } = requestOrigin; +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Enums/UploadFileType.cs b/src/Application/TakeoutSaaS.Application/Storage/Enums/UploadFileType.cs new file mode 100644 index 0000000..f6c5228 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Enums/UploadFileType.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.Storage.Enums; + +/// +/// 上传文件类型,映射业务场景。 +/// +public enum UploadFileType +{ + /// + /// 菜品图片。 + /// + DishImage = 1, + + /// + /// 商户 Logo。 + /// + MerchantLogo = 2, + + /// + /// 用户头像。 + /// + UserAvatar = 3, + + /// + /// 评价图片。 + /// + ReviewImage = 4, + + /// + /// 其他通用文件。 + /// + Other = 9 +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Extensions/StorageServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Storage/Extensions/StorageServiceCollectionExtensions.cs new file mode 100644 index 0000000..1301804 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Extensions/StorageServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Storage.Abstractions; +using TakeoutSaaS.Application.Storage.Services; + +namespace TakeoutSaaS.Application.Storage.Extensions; + +/// +/// 存储应用服务注册扩展。 +/// +public static class StorageServiceCollectionExtensions +{ + /// + /// 注册文件存储应用服务。 + /// + public static IServiceCollection AddStorageApplication(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs b/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs new file mode 100644 index 0000000..dd712ba --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs @@ -0,0 +1,46 @@ +using System; +using TakeoutSaaS.Application.Storage.Enums; + +namespace TakeoutSaaS.Application.Storage.Extensions; + +/// +/// 上传类型解析与辅助方法。 +/// +public static class UploadFileTypeParser +{ + /// + /// 将字符串解析为上传类型。 + /// + public static bool TryParse(string? value, out UploadFileType type) + { + type = UploadFileType.Other; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var normalized = value.Trim().ToLowerInvariant(); + type = normalized switch + { + "dish_image" => UploadFileType.DishImage, + "merchant_logo" => UploadFileType.MerchantLogo, + "user_avatar" => UploadFileType.UserAvatar, + "review_image" => UploadFileType.ReviewImage, + _ => UploadFileType.Other + }; + + return type != UploadFileType.Other || normalized == "other"; + } + + /// + /// 将上传类型转换为路径片段。 + /// + public static string ToFolderName(this UploadFileType type) => type switch + { + UploadFileType.DishImage => "dishes", + UploadFileType.MerchantLogo => "merchants", + UploadFileType.UserAvatar => "users", + UploadFileType.ReviewImage => "reviews", + _ => "files" + }; +} diff --git a/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs b/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs new file mode 100644 index 0000000..f2105c5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Application.Storage.Abstractions; +using TakeoutSaaS.Application.Storage.Contracts; +using TakeoutSaaS.Application.Storage.Enums; +using TakeoutSaaS.Application.Storage.Extensions; +using TakeoutSaaS.Module.Storage.Abstractions; +using TakeoutSaaS.Module.Storage.Models; +using TakeoutSaaS.Module.Storage.Options; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Storage.Services; + +/// +/// 文件存储应用服务,实现上传与直传凭证生成。 +/// +public sealed class FileStorageService( + IStorageProviderResolver providerResolver, + IOptionsMonitor optionsMonitor, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + ILogger logger) : IFileStorageService +{ + /// + public async Task UploadAsync(UploadFileRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new BusinessException(ErrorCodes.BadRequest, "上传请求不能为空"); + } + + var options = optionsMonitor.CurrentValue; + var security = options.Security; + ValidateOrigin(request.RequestOrigin, security); + ValidateFileSize(request.ContentLength, security); + + var extension = NormalizeExtension(request.FileName); + ValidateExtension(request.FileType, extension, security); + var contentType = NormalizeContentType(request.ContentType, extension); + ResetStream(request.Content); + + var objectKey = BuildObjectKey(request.FileType, extension); + var metadata = BuildMetadata(request.FileType); + var expires = TimeSpan.FromMinutes(Math.Max(1, security.DefaultUrlExpirationMinutes)); + var provider = providerResolver.Resolve(); + + var uploadResult = await provider.UploadAsync( + new StorageUploadRequest(objectKey, request.Content, contentType, request.ContentLength, true, expires, metadata), + cancellationToken).ConfigureAwait(false); + + var finalUrl = AppendAntiLeechToken(uploadResult.SignedUrl ?? uploadResult.Url, objectKey, expires, security); + logger.LogInformation("文件上传成功:{ObjectKey} ({Size} bytes)", objectKey, request.ContentLength); + + return new FileUploadResponse + { + Url = finalUrl, + FileName = Path.GetFileName(uploadResult.ObjectKey), + FileSize = uploadResult.FileSize + }; + } + + /// + public async Task CreateDirectUploadAsync(DirectUploadRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new BusinessException(ErrorCodes.BadRequest, "直传请求不能为空"); + } + + var options = optionsMonitor.CurrentValue; + var security = options.Security; + ValidateOrigin(request.RequestOrigin, security); + ValidateFileSize(request.ContentLength, security); + + var extension = NormalizeExtension(request.FileName); + ValidateExtension(request.FileType, extension, security); + var contentType = NormalizeContentType(request.ContentType, extension); + + var objectKey = BuildObjectKey(request.FileType, extension); + var provider = providerResolver.Resolve(); + var expires = TimeSpan.FromMinutes(Math.Max(1, security.DefaultUrlExpirationMinutes)); + + var directResult = await provider.CreateDirectUploadAsync( + new StorageDirectUploadRequest(objectKey, contentType, request.ContentLength, expires), + cancellationToken).ConfigureAwait(false); + + var finalDownloadUrl = directResult.SignedDownloadUrl != null + ? AppendAntiLeechToken(directResult.SignedDownloadUrl, objectKey, expires, security) + : null; + + return new DirectUploadResponse + { + UploadUrl = directResult.UploadUrl, + FormFields = directResult.FormFields, + ExpiresAt = directResult.ExpiresAt, + ObjectKey = directResult.ObjectKey, + DownloadUrl = finalDownloadUrl + }; + } + + /// + /// 校验文件大小。 + /// + private static void ValidateFileSize(long size, StorageSecurityOptions security) + { + if (size <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "文件内容为空"); + } + + if (size > security.MaxFileSizeBytes) + { + throw new BusinessException(ErrorCodes.BadRequest, $"文件过大,最大允许 {security.MaxFileSizeBytes / 1024 / 1024}MB"); + } + } + + /// + /// 校验文件后缀是否符合配置。 + /// + private static void ValidateExtension(UploadFileType type, string extension, StorageSecurityOptions security) + { + var allowedImages = security.AllowedImageExtensions ?? Array.Empty(); + var allowedFiles = security.AllowedFileExtensions ?? Array.Empty(); + + if (type is UploadFileType.DishImage or UploadFileType.MerchantLogo or UploadFileType.UserAvatar or UploadFileType.ReviewImage) + { + if (!allowedImages.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + throw new BusinessException(ErrorCodes.BadRequest, $"不支持的图片格式:{extension}"); + } + } + else if (!allowedFiles.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + throw new BusinessException(ErrorCodes.BadRequest, $"不支持的文件格式:{extension}"); + } + } + + /// + /// 统一化文件后缀(小写,默认 .bin)。 + /// + private static string NormalizeExtension(string fileName) + { + var extension = Path.GetExtension(fileName); + if (string.IsNullOrWhiteSpace(extension)) + { + return ".bin"; + } + + return extension.ToLowerInvariant(); + } + + /// + /// 根据内容类型或后缀推断 Content-Type。 + /// + private static string NormalizeContentType(string contentType, string extension) + { + if (!string.IsNullOrWhiteSpace(contentType)) + { + return contentType; + } + + return extension switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".pdf" => "application/pdf", + _ => "application/octet-stream" + }; + } + + /// + /// 校验请求来源是否在白名单内。 + /// + private void ValidateOrigin(string? origin, StorageSecurityOptions security) + { + if (!security.EnableRefererValidation || security.AllowedReferers.Length == 0) + { + return; + } + + if (string.IsNullOrWhiteSpace(origin)) + { + throw new BusinessException(ErrorCodes.Forbidden, "未授权的访问来源"); + } + + var isAllowed = security.AllowedReferers.Any(allowed => + !string.IsNullOrWhiteSpace(allowed) && + origin.StartsWith(allowed, StringComparison.OrdinalIgnoreCase)); + + if (!isAllowed) + { + throw new BusinessException(ErrorCodes.Forbidden, "访问来源未在白名单中"); + } + } + + /// + /// 生成对象存储的键路径。 + /// + private string BuildObjectKey(UploadFileType type, string extension) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var tenantSegment = tenantId == 0 ? "platform" : tenantId.ToString(); + var folder = type.ToFolderName(); + var now = DateTime.UtcNow; + var fileName = $"{Guid.NewGuid():N}{extension}"; + + return $"{tenantSegment}/{folder}/{now:yyyy/MM/dd}/{fileName}"; + } + + /// + /// 组装对象元数据,便于追踪租户与用户。 + /// + private IDictionary BuildMetadata(UploadFileType type) + { + var metadata = new Dictionary + { + ["x-meta-upload-type"] = type.ToString(), + ["x-meta-tenant-id"] = tenantProvider.GetCurrentTenantId().ToString() + }; + + if (currentUserAccessor.IsAuthenticated) + { + metadata["x-meta-user-id"] = currentUserAccessor.UserId.ToString(); + } + + return metadata; + } + + /// + /// 重置文件流的读取位置。 + /// + private static void ResetStream(Stream stream) + { + if (stream.CanSeek) + { + stream.Position = 0; + } + } + + /// + /// 为访问链接追加防盗链签名(可配合 CDN Token 验证)。 + /// + private static string AppendAntiLeechToken(string url, string objectKey, TimeSpan expires, StorageSecurityOptions security) + { + if (string.IsNullOrWhiteSpace(security.AntiLeechTokenSecret)) + { + return url; + } + + // 若链接已包含云厂商签名参数,则避免追加自定义参数导致验签失败。 + if (url.Contains("X-Amz-Signature", StringComparison.OrdinalIgnoreCase) || + url.Contains("q-sign-algorithm", StringComparison.OrdinalIgnoreCase) || + url.Contains("Signature=", StringComparison.OrdinalIgnoreCase)) + { + return url; + } + + var expireAt = DateTimeOffset.UtcNow.Add(expires).ToUnixTimeSeconds(); + var payload = $"{objectKey}:{expireAt}:{security.AntiLeechTokenSecret}"; + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(payload)); + var token = Convert.ToHexString(hashBytes).ToLowerInvariant(); + var separator = url.Contains('?', StringComparison.Ordinal) ? "&" : "?"; + return $"{url}{separator}ts={expireAt}&token={token}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj new file mode 100644 index 0000000..acb8fc2 Binary files /dev/null and b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj differ diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs new file mode 100644 index 0000000..cf8a78e --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Shared.Abstractions.Constants; + +/// +/// 数据源名称常量,统一配置键与使用说明。 +/// +public static class DatabaseConstants +{ + /// + /// 默认业务库(AppDatabase)。 + /// + public const string AppDataSource = "AppDatabase"; + + /// + /// 身份认证库(IdentityDatabase)。 + /// + public const string IdentityDataSource = "IdentityDatabase"; + + /// + /// 字典库(DictionaryDatabase)。 + /// + public const string DictionaryDataSource = "DictionaryDatabase"; +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs new file mode 100644 index 0000000..2d7ed97 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs @@ -0,0 +1,19 @@ +namespace TakeoutSaaS.Shared.Abstractions.Constants; + +/// +/// 统一错误码常量。 +/// +public static class ErrorCodes +{ + public const int BadRequest = 400; + public const int Unauthorized = 401; + public const int Forbidden = 403; + public const int NotFound = 404; + public const int Conflict = 409; + public const int ValidationFailed = 422; + public const int InternalServerError = 500; + + // 业务自定义区间(10000+) + public const int BusinessError = 10001; +} + diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Data/DatabaseConnectionRole.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/DatabaseConnectionRole.cs new file mode 100644 index 0000000..175f9f6 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/DatabaseConnectionRole.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Shared.Abstractions.Data; + +/// +/// 数据库连接角色,用于区分主写与从读连接。 +/// +public enum DatabaseConnectionRole +{ + /// + /// 主写连接,用于写入或强一致读。 + /// + Write = 1, + + /// + /// 从读连接,用于只读查询或报表。 + /// + Read = 2 +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs new file mode 100644 index 0000000..3783a93 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs @@ -0,0 +1,47 @@ +using System.Data; + +namespace TakeoutSaaS.Shared.Abstractions.Data; + +/// +/// Dapper 查询/命令执行器抽象,封装连接获取与读写路由。 +/// +public interface IDapperExecutor +{ + /// + /// 使用指定数据源与读写角色执行异步查询,并返回结果。 + /// + /// 查询结果类型。 + /// 逻辑数据源名称。 + /// 连接角色(读/写)。 + /// 查询委托,提供已打开的连接和取消标记。 + /// 取消标记。 + /// 查询结果。 + Task QueryAsync( + string dataSourceName, + DatabaseConnectionRole role, + Func> query, + CancellationToken cancellationToken = default); + + /// + /// 使用指定数据源与读写角色执行异步命令。 + /// + /// 逻辑数据源名称。 + /// 连接角色(读/写)。 + /// 命令委托,提供已打开的连接和取消标记。 + /// 取消标记。 + Task ExecuteAsync( + string dataSourceName, + DatabaseConnectionRole role, + Func command, + CancellationToken cancellationToken = default); + + /// + /// 获取指定数据源及角色的默认命令超时时间(秒)。 + /// + /// 逻辑数据源名称。 + /// 连接角色,默认读取从库。 + /// 命令超时时间(秒)。 + int GetDefaultCommandTimeoutSeconds( + string dataSourceName, + DatabaseConnectionRole role = DatabaseConnectionRole.Read); +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs new file mode 100644 index 0000000..ad8aa43 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs @@ -0,0 +1,39 @@ +using System.Threading; + +namespace TakeoutSaaS.Shared.Abstractions.Diagnostics; + +/// +/// 轻量级 TraceId/SpanId 上下文,便于跨层访问当前请求的追踪标识。 +/// +public static class TraceContext +{ + private static readonly AsyncLocal TraceIdHolder = new(); + private static readonly AsyncLocal SpanIdHolder = new(); + + /// + /// 当前请求的 TraceId。 + /// + public static string? TraceId + { + get => TraceIdHolder.Value; + set => TraceIdHolder.Value = value; + } + + /// + /// 当前请求的 SpanId。 + /// + public static string? SpanId + { + get => SpanIdHolder.Value; + set => SpanIdHolder.Value = value; + } + + /// + /// 清理 TraceId,避免 AsyncLocal 污染其它请求。 + /// + public static void Clear() + { + TraceIdHolder.Value = null; + SpanIdHolder.Value = null; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/AuditableEntityBase.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/AuditableEntityBase.cs new file mode 100644 index 0000000..6dbcf9c --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/AuditableEntityBase.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 审计实体基类:提供创建、更新时间以及软删除时间。 +/// +public abstract class AuditableEntityBase : EntityBase, IAuditableEntity +{ + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; set; } + + /// + /// 最近一次更新时间(UTC),从未更新时为 null。 + /// + public DateTime? UpdatedAt { get; set; } + + /// + /// 软删除时间(UTC),未删除时为 null。 + /// + public DateTime? DeletedAt { get; set; } + + /// + /// 创建人用户标识,匿名或系统操作时为 null。 + /// + public long? CreatedBy { get; set; } + + /// + /// 最后更新人用户标识,匿名或系统操作时为 null。 + /// + public long? UpdatedBy { get; set; } + + /// + /// 删除人用户标识(软删除),未删除时为 null。 + /// + public long? DeletedBy { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/EntityBase.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/EntityBase.cs new file mode 100644 index 0000000..e1e2aa4 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/EntityBase.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 实体基类,统一提供主键标识。 +/// +public abstract class EntityBase +{ + /// + /// 实体唯一标识。 + /// + public long Id { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs new file mode 100644 index 0000000..844ad54 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 审计字段接口:提供创建、更新、删除时间与操作者标识。 +/// +public interface IAuditableEntity : ISoftDeleteEntity +{ + /// + /// 创建时间(UTC)。 + /// + DateTime CreatedAt { get; set; } + + /// + /// 更新时间(UTC),未更新时为 null。 + /// + DateTime? UpdatedAt { get; set; } + + /// + /// 删除时间(UTC),未删除时为 null。 + /// + new DateTime? DeletedAt { get; set; } + + /// + /// 创建人用户标识,匿名或系统操作时为 null。 + /// + long? CreatedBy { get; set; } + + /// + /// 最后更新人用户标识,匿名或系统操作时为 null。 + /// + long? UpdatedBy { get; set; } + + /// + /// 删除人用户标识(软删除),未删除时为 null。 + /// + long? DeletedBy { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IMultiTenantEntity.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IMultiTenantEntity.cs new file mode 100644 index 0000000..1a0fecd --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IMultiTenantEntity.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 多租户实体约定:所有持久化实体须包含租户标识字段。 +/// +public interface IMultiTenantEntity +{ + /// + /// 所属租户 ID。 + /// + long TenantId { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/ISoftDeleteEntity.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/ISoftDeleteEntity.cs new file mode 100644 index 0000000..4bc3fba --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/ISoftDeleteEntity.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 软删除实体约定:提供可空的删除时间戳以支持全局过滤。 +/// +public interface ISoftDeleteEntity +{ + /// + /// 删除时间(UTC),未删除时为 null。 + /// + DateTime? DeletedAt { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/MultiTenantEntityBase.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/MultiTenantEntityBase.cs new file mode 100644 index 0000000..df6417e --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/MultiTenantEntityBase.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 多租户审计实体基类:提供租户标识、审计字段与软删除标记。 +/// +public abstract class MultiTenantEntityBase : AuditableEntityBase, IMultiTenantEntity +{ + /// + /// 所属租户 ID。 + /// + public long TenantId { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/BusinessException.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/BusinessException.cs new file mode 100644 index 0000000..b14dc4a --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/BusinessException.cs @@ -0,0 +1,13 @@ +namespace TakeoutSaaS.Shared.Abstractions.Exceptions; + +/// +/// 业务异常(用于可预期的业务校验错误)。 +/// +public class BusinessException(int errorCode, string message) : Exception(message) +{ + /// + /// 业务错误码。 + /// + public int ErrorCode { get; } = errorCode; +} + diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs new file mode 100644 index 0000000..a87def6 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace TakeoutSaaS.Shared.Abstractions.Exceptions; + +/// +/// 验证异常(用于聚合验证错误信息)。 +/// +public class ValidationException(IDictionary errors) : Exception("一个或多个验证错误") +{ + /// + /// 字段/属性的错误集合。 + /// + public IDictionary Errors { get; } = errors; +} + diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IIdGenerator.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IIdGenerator.cs new file mode 100644 index 0000000..ce8dff4 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IIdGenerator.cs @@ -0,0 +1,13 @@ +namespace TakeoutSaaS.Shared.Abstractions.Ids; + +/// +/// 雪花 ID 生成器接口。 +/// +public interface IIdGenerator +{ + /// + /// 生成下一个唯一长整型 ID。 + /// + /// 雪花 ID。 + long NextId(); +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IdGeneratorOptions.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IdGeneratorOptions.cs new file mode 100644 index 0000000..6d9b40f --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IdGeneratorOptions.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Shared.Abstractions.Ids; + +/// +/// 雪花 ID 生成器配置。 +/// +public sealed class IdGeneratorOptions +{ + /// + /// 配置节名称。 + /// + public const string SectionName = "IdGenerator"; + + /// + /// 工作节点标识,0-31。 + /// + [Range(0, 31)] + public int WorkerId { get; set; } + + /// + /// 机房标识,0-31。 + /// + [Range(0, 31)] + public int DatacenterId { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs new file mode 100644 index 0000000..d3e8d6d --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs @@ -0,0 +1,31 @@ +namespace TakeoutSaaS.Shared.Abstractions.Results; + +/// +/// 非泛型便捷封装。 +/// +public static class ApiResponse +{ + /// + /// 仅返回成功消息(无数据)。 + /// + public static ApiResponse Success(string? message = "操作成功") + => ApiResponse.Ok(message: message); + + /// + /// 成功且携带数据。 + /// + public static ApiResponse Ok(object? data, string? message = "操作成功") + => data is null ? ApiResponse.Ok(message: message) : ApiResponse.Ok(data, message); + + /// + /// 错误返回。 + /// + public static ApiResponse Failure(int code, string message) + => ApiResponse.Error(code, message); + + /// + /// 错误返回(附带详情)。 + /// + public static ApiResponse Error(int code, string message, object? errors = null) + => ApiResponse.Error(code, message, errors); +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs new file mode 100644 index 0000000..b49a215 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs @@ -0,0 +1,163 @@ +using System.Diagnostics; +using TakeoutSaaS.Shared.Abstractions.Diagnostics; + +namespace TakeoutSaaS.Shared.Abstractions.Results; + +/// +/// 统一的 API 返回结果包装。 +/// +/// 数据载荷类型 +public sealed record ApiResponse +{ + /// + /// 是否成功。 + /// + public bool Success { get; init; } + + /// + /// 状态/错误码(默认 200)。 + /// + public int Code { get; init; } = 200; + + /// + /// 提示信息。 + /// + public string? Message { get; init; } + + /// + /// 业务数据。 + /// + public T? Data { get; init; } + + /// + /// 错误详情(如字段验证错误)。 + /// + public object? Errors { get; init; } + + /// + /// TraceId,便于链路追踪。 + /// + public string TraceId { get; init; } = string.Empty; + + /// + /// 时间戳(UTC)。 + /// + public DateTime Timestamp { get; init; } = DateTime.UtcNow; + + /// + /// 成功返回。 + /// + public static ApiResponse Ok(T data, string? message = "操作成功") + => Create(true, 200, message, data); + + /// + /// 无数据的成功返回。 + /// + public static ApiResponse Ok(string? message = "操作成功") + => Create(true, 200, message, default); + + /// + /// 兼容旧名称:成功结果。 + /// + public static ApiResponse SuccessResult(T data, string? message = "操作成功") + => Ok(data, message); + + /// + /// 错误返回。 + /// + public static ApiResponse Error(int code, string message, object? errors = null) + => Create(false, code, message, default, errors); + + /// + /// 兼容旧名称:失败结果。 + /// + public static ApiResponse Failure(int code, string message) + => Error(code, message); + + /// + /// 附加错误详情。 + /// + public ApiResponse WithErrors(object? errors) + => this with { Errors = errors }; + + private static ApiResponse Create(bool success, int code, string? message, T? data, object? errors = null) + => new() + { + Success = success, + Code = code, + Message = message, + Data = data, + Errors = errors, + TraceId = ResolveTraceId(), + Timestamp = DateTime.UtcNow + }; + + private static string ResolveTraceId() + { + if (!string.IsNullOrWhiteSpace(TraceContext.TraceId)) + { + return TraceContext.TraceId; + } + + if (!string.IsNullOrWhiteSpace(TraceContext.TraceId)) + { + return TraceContext.TraceId; + } + + if (Activity.Current?.Id is { } id && !string.IsNullOrWhiteSpace(id)) + { + return id; + } + + return IdFallbackGenerator.Instance.NextId().ToString(); + } +} + +internal sealed class IdFallbackGenerator +{ + private static readonly Lazy Lazy = new(() => new IdFallbackGenerator()); + public static IdFallbackGenerator Instance => Lazy.Value; + + private readonly object _sync = new(); + private long _lastTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + private long _sequence; + + private IdFallbackGenerator() + { + } + + public long NextId() + { + lock (_sync) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (timestamp == _lastTimestamp) + { + _sequence = (_sequence + 1) & 4095; + if (_sequence == 0) + { + timestamp = WaitNextMillis(_lastTimestamp); + } + } + else + { + _sequence = 0; + } + + _lastTimestamp = timestamp; + return ((timestamp - 1577836800000L) << 22) | _sequence; + } + } + + private static long WaitNextMillis(long lastTimestamp) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + while (timestamp <= lastTimestamp) + { + Thread.SpinWait(100); + timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + + return timestamp; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs new file mode 100644 index 0000000..69c5ba4 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace TakeoutSaaS.Shared.Abstractions.Results; + +/// +/// 分页结果包装,携带列表与总条数等元数据。 +/// +/// 数据类型。 +/// +/// 初始化分页结果。 +/// +public sealed class PagedResult(IReadOnlyList items, int page, int pageSize, int totalCount) +{ + /// + /// 数据列表。 + /// + public IReadOnlyList Items { get; } = items; + + /// + /// 当前页码,从 1 开始。 + /// + public int Page { get; } = page; + + /// + /// 每页条数。 + /// + public int PageSize { get; } = pageSize; + + /// + /// 总条数。 + /// + public int TotalCount { get; } = totalCount; + + /// + /// 总页数。 + /// + public int TotalPages { get; } = pageSize == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize); +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Security/ICurrentUserAccessor.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Security/ICurrentUserAccessor.cs new file mode 100644 index 0000000..ba0d7e2 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Security/ICurrentUserAccessor.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Shared.Abstractions.Security; + +/// +/// 当前用户访问器:提供与当前请求相关的用户标识信息。 +/// +public interface ICurrentUserAccessor +{ + /// + /// 当前用户 ID,未登录时为 Guid.Empty。 + /// + long UserId { get; } + + /// + /// 是否已登录。 + /// + bool IsAuthenticated { get; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Serialization/SnowflakeIdJsonConverter.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Serialization/SnowflakeIdJsonConverter.cs new file mode 100644 index 0000000..b7f82a0 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Serialization/SnowflakeIdJsonConverter.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TakeoutSaaS.Shared.Abstractions.Serialization; + +/// +/// 将 long 类型的雪花 ID 以字符串形式序列化/反序列化,避免前端精度丢失。 +/// +public sealed class SnowflakeIdJsonConverter : JsonConverter +{ + /// + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.Number => reader.GetInt64(), + JsonTokenType.String when long.TryParse(reader.GetString(), out var value) => value, + JsonTokenType.Null => 0, + _ => throw new JsonException("无法解析雪花 ID") + }; + } + + /// + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) + { + writer.WriteStringValue(value == 0 ? "0" : value.ToString()); + } +} + +/// +/// 可空雪花 ID 转换器。 +/// +public sealed class NullableSnowflakeIdJsonConverter : JsonConverter +{ + /// + public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.Number => reader.GetInt64(), + JsonTokenType.String when long.TryParse(reader.GetString(), out var value) => value, + JsonTokenType.Null => null, + _ => throw new JsonException("无法解析雪花 ID") + }; + } + + /// + public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.HasValue ? value.Value.ToString() : null); + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/TakeoutSaaS.Shared.Abstractions.csproj b/src/Core/TakeoutSaaS.Shared.Abstractions/TakeoutSaaS.Shared.Abstractions.csproj new file mode 100644 index 0000000..2de50de --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/TakeoutSaaS.Shared.Abstractions.csproj @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + true + 1591 + + + diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantContextAccessor.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantContextAccessor.cs new file mode 100644 index 0000000..c05f027 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantContextAccessor.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Tenancy; + +/// +/// 租户上下文访问器:用于在请求生命周期内读写当前租户上下文。 +/// +public interface ITenantContextAccessor +{ + /// + /// 获取或设置当前租户上下文。 + /// + TenantContext? Current { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs new file mode 100644 index 0000000..02c818a --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Tenancy; + +/// +/// 租户提供者:用于在各层读取当前请求绑定的租户 ID。 +/// +public interface ITenantProvider +{ + /// + /// 获取当前租户 ID,未解析时返回 Guid.Empty。 + /// + long GetCurrentTenantId(); +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantConstants.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantConstants.cs new file mode 100644 index 0000000..d5638fa --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantConstants.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Tenancy; + +/// +/// 多租户相关通用常量。 +/// +public static class TenantConstants +{ + /// + /// HttpContext.Items 中租户上下文的键名。 + /// + public const string HttpContextItemKey = "__tenant_context"; +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantContext.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantContext.cs new file mode 100644 index 0000000..8ba5da6 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantContext.cs @@ -0,0 +1,38 @@ +namespace TakeoutSaaS.Shared.Abstractions.Tenancy; + +/// +/// 租户上下文:封装当前请求解析得到的租户标识、编号及解析来源。 +/// +/// +/// 初始化租户上下文。 +/// +/// 租户 ID +/// 租户编码(可选) +/// 解析来源 +public sealed class TenantContext(long tenantId, string? tenantCode, string source) +{ + /// + /// 未解析到租户时的默认上下文。 + /// + public static TenantContext Empty { get; } = new(0, null, "unresolved"); + + /// + /// 当前租户 ID,未解析时为 Guid.Empty。 + /// + public long TenantId { get; } = tenantId; + + /// + /// 当前租户编码(例如子域名或业务编码),可为空。 + /// + public string? TenantCode { get; } = tenantCode; + + /// + /// 租户解析来源(Header、Host、Token 等)。 + /// + public string Source { get; } = source; + + /// + /// 是否已成功解析到租户。 + /// + public bool IsResolved => TenantId != 0; +} diff --git a/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs b/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs new file mode 100644 index 0000000..06d22fb --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs @@ -0,0 +1,105 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using System.Threading; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Shared.Kernel.Ids; + +/// +/// 基于雪花算法的长整型 ID 生成器。 +/// +/// +/// 初始化生成器。 +/// +/// 工作节点 ID。 +/// 机房 ID。 +public sealed class SnowflakeIdGenerator(long workerId = 0, long datacenterId = 0) : IIdGenerator +{ + private const long Twepoch = 1577836800000L; // 2020-01-01 UTC + private const int WorkerIdBits = 5; + private const int DatacenterIdBits = 5; + private const int SequenceBits = 12; + + private const long MaxWorkerId = -1L ^ (-1L << WorkerIdBits); + private const long MaxDatacenterId = -1L ^ (-1L << DatacenterIdBits); + + private const int WorkerIdShift = SequenceBits; + private const int DatacenterIdShift = SequenceBits + WorkerIdBits; + private const int TimestampLeftShift = SequenceBits + WorkerIdBits + DatacenterIdBits; + private const long SequenceMask = -1L ^ (-1L << SequenceBits); + + private readonly long _workerId = Normalize(workerId, MaxWorkerId, nameof(workerId)); + private readonly long _datacenterId = Normalize(datacenterId, MaxDatacenterId, nameof(datacenterId)); + private long _lastTimestamp = -1L; + private long _sequence = RandomNumberGenerator.GetInt32(0, (int)SequenceMask); + private readonly object _syncRoot = new(); + + + /// + public long NextId() + { + lock (_syncRoot) + { + var timestamp = CurrentTimeMillis(); + + if (timestamp < _lastTimestamp) + { + // 时钟回拨时等待到下一毫秒。 + var wait = _lastTimestamp - timestamp; + Thread.Sleep(TimeSpan.FromMilliseconds(wait)); + timestamp = CurrentTimeMillis(); + if (timestamp < _lastTimestamp) + { + throw new InvalidOperationException($"系统时钟回拨 {_lastTimestamp - timestamp} 毫秒,无法生成 ID。"); + } + } + + if (_lastTimestamp == timestamp) + { + _sequence = (_sequence + 1) & SequenceMask; + if (_sequence == 0) + { + timestamp = WaitNextMillis(_lastTimestamp); + } + } + else + { + _sequence = 0; + } + + _lastTimestamp = timestamp; + + var id = ((timestamp - Twepoch) << TimestampLeftShift) + | (_datacenterId << DatacenterIdShift) + | (_workerId << WorkerIdShift) + | _sequence; + + Debug.Assert(id > 0); + return id; + } + } + + private static long WaitNextMillis(long lastTimestamp) + { + var timestamp = CurrentTimeMillis(); + while (timestamp <= lastTimestamp) + { + Thread.SpinWait(50); + timestamp = CurrentTimeMillis(); + } + + return timestamp; + } + + private static long CurrentTimeMillis() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + private static long Normalize(long value, long max, string name) + { + if (value < 0 || value > max) + { + throw new ArgumentOutOfRangeException(name, value, $"取值范围 0~{max}"); + } + + return value; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Kernel/TakeoutSaaS.Shared.Kernel.csproj b/src/Core/TakeoutSaaS.Shared.Kernel/TakeoutSaaS.Shared.Kernel.csproj new file mode 100644 index 0000000..c52f050 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Kernel/TakeoutSaaS.Shared.Kernel.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + + + + + + diff --git a/src/Core/TakeoutSaaS.Shared.Web/Api/BaseApiController.cs b/src/Core/TakeoutSaaS.Shared.Web/Api/BaseApiController.cs new file mode 100644 index 0000000..af1240e --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Api/BaseApiController.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; + +namespace TakeoutSaaS.Shared.Web.Api; + +/// +/// API 基类控制器: +/// - 统一应用 [ApiController] 和默认响应类型 +/// - 作为所有 API 控制器的基类,便于复用过滤器/中间件特性 +/// +[ApiController] +[Produces("application/json")] +public abstract class BaseApiController : ControllerBase +{ +} + diff --git a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ApplicationBuilderExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..2c25aaa --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Builder; +using TakeoutSaaS.Shared.Web.Middleware; + +namespace TakeoutSaaS.Shared.Web.Extensions; + +/// +/// Web 应用中间件扩展。 +/// +public static class ApplicationBuilderExtensions +{ + /// + /// 按规范启用 TraceId、请求日志、异常映射与安全响应头。 + /// + public static IApplicationBuilder UseSharedWebCore(this IApplicationBuilder app) + { + app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + return app; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..dd5f1e3 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Web.Filters; +using TakeoutSaaS.Shared.Web.Security; + +namespace TakeoutSaaS.Shared.Web.Extensions; + +/// +/// Shared.Web 服务注册扩展。 +/// +public static class ServiceCollectionExtensions +{ + /// + /// 注册控制器、模型验证、API 版本化等基础能力。 + /// + public static IServiceCollection AddSharedWebCore(this IServiceCollection services) + { + services.AddHttpContextAccessor(); + services.AddEndpointsApiExplorer(); + services.AddScoped(); + + services + .AddControllers(options => + { + options.Filters.Add(); + options.Filters.Add(); + }) + .AddNewtonsoftJson(); + + services.Configure(options => + { + options.SuppressModelStateInvalidFilter = true; + }); + + services.AddApiVersioning(options => + { + options.AssumeDefaultVersionWhenUnspecified = true; + options.DefaultApiVersion = new ApiVersion(1, 0); + options.ReportApiVersions = true; + }); + + services.AddVersionedApiExplorer(setup => + { + setup.GroupNameFormat = "'v'VVV"; + setup.SubstituteApiVersionInUrl = true; + }); + + return services; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Filters/ApiResponseResultFilter.cs b/src/Core/TakeoutSaaS.Shared.Web/Filters/ApiResponseResultFilter.cs new file mode 100644 index 0000000..4877e1e --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Filters/ApiResponseResultFilter.cs @@ -0,0 +1,104 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Shared.Web.Filters; + +/// +/// ApiResponse 结果过滤器:自动将 ApiResponse 转换为对应的 HTTP 状态码。 +/// 使用此过滤器后,控制器可以直接返回 ApiResponse<T>,无需再包一层 Ok() 或 Unauthorized()。 +/// +public sealed class ApiResponseResultFilter : IAsyncResultFilter +{ + public Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) + { + // 只处理 ObjectResult 类型的结果 + if (context.Result is not ObjectResult objectResult) + { + return next(); + } + + var value = objectResult.Value; + if (value == null) + { + return next(); + } + + // 检查是否是 ApiResponse 类型 + var valueType = value.GetType(); + if (!IsApiResponseType(valueType)) + { + return next(); + } + + // 使用反射获取 Success 和 Code 属性 + // 注意:由于已通过 IsApiResponseType 检查,属性名是固定的 + const string successPropertyName = "Success"; + const string codePropertyName = "Code"; + var successProperty = valueType.GetProperty(successPropertyName); + var codeProperty = valueType.GetProperty(codePropertyName); + + if (successProperty == null || codeProperty == null) + { + return next(); + } + + var success = (bool)(successProperty.GetValue(value) ?? false); + var code = (int)(codeProperty.GetValue(value) ?? 200); + + // 根据 Success 和 Code 设置 HTTP 状态码 + var statusCode = success ? MapSuccessCode(code) : MapErrorCode(code); + + // 更新 ObjectResult 的状态码 + objectResult.StatusCode = statusCode; + + return next(); + } + + private static bool IsApiResponseType(Type type) + { + // 检查是否是 ApiResponse 类型 + if (type.IsGenericType) + { + var genericTypeDefinition = type.GetGenericTypeDefinition(); + return genericTypeDefinition == typeof(ApiResponse<>); + } + + return false; + } + + private static int MapSuccessCode(int code) + { + // 成功情况下,通常返回 200 + // 但也可以根据业务码返回其他成功状态码(如 201 Created) + return code switch + { + 200 => StatusCodes.Status200OK, + 201 => StatusCodes.Status201Created, + 204 => StatusCodes.Status204NoContent, + _ => StatusCodes.Status200OK + }; + } + + private static int MapErrorCode(int code) + { + // 根据业务错误码映射到 HTTP 状态码 + return code switch + { + ErrorCodes.BadRequest => StatusCodes.Status400BadRequest, + ErrorCodes.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorCodes.Forbidden => StatusCodes.Status403Forbidden, + ErrorCodes.NotFound => StatusCodes.Status404NotFound, + ErrorCodes.Conflict => StatusCodes.Status409Conflict, + ErrorCodes.ValidationFailed => StatusCodes.Status422UnprocessableEntity, + ErrorCodes.InternalServerError => StatusCodes.Status500InternalServerError, + // 业务错误码(10000+)统一返回 422 + >= 10000 => StatusCodes.Status422UnprocessableEntity, + // 默认返回 400 + _ => StatusCodes.Status400BadRequest + }; + } +} + diff --git a/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs b/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs new file mode 100644 index 0000000..4c802e0 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs @@ -0,0 +1,29 @@ +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Shared.Web.Filters; + +/// +/// 模型验证过滤器:将模型验证错误统一为ApiResponse输出 +/// +public sealed class ValidateModelAttribute : ActionFilterAttribute +{ + public override void OnActionExecuting(ActionExecutingContext context) + { + if (!context.ModelState.IsValid) + { + var errors = context.ModelState + .Where(kv => kv.Value?.Errors.Count > 0) + .ToDictionary( + kv => kv.Key, + kv => kv.Value!.Errors.Select(e => string.IsNullOrWhiteSpace(e.ErrorMessage) ? "Invalid" : e.ErrorMessage).ToArray() + ); + + var response = ApiResponse.Error(ErrorCodes.ValidationFailed, "一个或多个验证错误", errors); + context.Result = new UnprocessableEntityObjectResult(response); + } + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..e1dadd9 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Shared.Abstractions.Diagnostics; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Shared.Web.Middleware; + +/// +/// 统一 TraceId/CorrelationId,贯穿日志与响应。 +/// +public sealed class CorrelationIdMiddleware(RequestDelegate next, ILogger logger, IIdGenerator idGenerator) +{ + private const string TraceHeader = "X-Trace-Id"; + private const string SpanHeader = "X-Span-Id"; + private const string RequestHeader = "X-Request-Id"; + + public async Task InvokeAsync(HttpContext context) + { + var ownsActivity = Activity.Current is null; + var activity = Activity.Current ?? new Activity("TakeoutSaaS.Request"); + + if (activity.Id is null) + { + activity.SetIdFormat(ActivityIdFormat.W3C); + activity.Start(); + } + + var traceId = activity.TraceId.ToString(); + var spanId = activity.SpanId.ToString(); + + if (string.IsNullOrWhiteSpace(traceId)) + { + traceId = ResolveTraceId(context); + } + + context.TraceIdentifier = traceId; + TraceContext.TraceId = traceId; + TraceContext.SpanId = spanId; + + context.Response.OnStarting(() => + { + context.Response.Headers[TraceHeader] = traceId; + context.Response.Headers[SpanHeader] = spanId; + return Task.CompletedTask; + }); + + using (logger.BeginScope(new Dictionary + { + ["TraceId"] = traceId, + ["SpanId"] = spanId + })) + { + try + { + await next(context); + } + finally + { + TraceContext.Clear(); + if (ownsActivity) + { + activity.Stop(); + } + } + } + } + + private string ResolveTraceId(HttpContext context) + { + if (TryGetHeader(context, TraceHeader, out var traceId)) + { + return traceId; + } + + if (TryGetHeader(context, RequestHeader, out var requestId)) + { + return requestId; + } + + return idGenerator.NextId().ToString(); + } + + private static bool TryGetHeader(HttpContext context, string headerName, out string value) + { + if (context.Request.Headers.TryGetValue(headerName, out var values)) + { + var headerValue = values.ToString(); + if (!string.IsNullOrWhiteSpace(headerValue)) + { + value = headerValue; + return true; + } + } + + value = string.Empty; + return false; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..2babea2 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,75 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Shared.Web.Middleware; + +/// +/// 全局异常处理中间件,将异常统一映射为 ApiResponse。 +/// +public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment environment) +{ + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception ex) + { + logger.LogError(ex, "未处理异常:{Message}", ex.Message); + await HandleExceptionAsync(context, ex); + } + } + + private Task HandleExceptionAsync(HttpContext context, Exception exception) + { + var (statusCode, response) = BuildErrorResponse(exception); + + if (environment.IsDevelopment()) + { + response = response with + { + Message = exception.Message, + Errors = new + { + response.Errors, + detail = exception.ToString() + } + }; + } + + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json"; + return context.Response.WriteAsJsonAsync(response, SerializerOptions); + } + + private static (int StatusCode, ApiResponse Response) BuildErrorResponse(Exception exception) + { + return exception switch + { + ValidationException validationException => ( + StatusCodes.Status422UnprocessableEntity, + ApiResponse.Error(ErrorCodes.ValidationFailed, "请求参数验证失败", validationException.Errors)), + BusinessException businessException => ( + StatusCodes.Status422UnprocessableEntity, + ApiResponse.Error(businessException.ErrorCode, businessException.Message)), + _ => ( + StatusCodes.Status500InternalServerError, + ApiResponse.Error(ErrorCodes.InternalServerError, "服务器开小差啦,请稍后再试")) + }; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs new file mode 100644 index 0000000..7c5be5f --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs @@ -0,0 +1,37 @@ +using System.Diagnostics; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Shared.Abstractions.Diagnostics; + +namespace TakeoutSaaS.Shared.Web.Middleware; + +/// +/// 基础请求日志(方法、路径、耗时、状态码、TraceId)。 +/// +public sealed class RequestLoggingMiddleware(RequestDelegate next, ILogger logger) +{ + + public async Task InvokeAsync(HttpContext context) + { + var stopwatch = Stopwatch.StartNew(); + try + { + await next(context); + } + finally + { + stopwatch.Stop(); + var traceId = TraceContext.TraceId ?? context.TraceIdentifier; + var spanId = TraceContext.SpanId ?? Activity.Current?.SpanId.ToString() ?? string.Empty; + logger.LogInformation( + "HTTP {Method} {Path} => {StatusCode} ({Elapsed} ms) TraceId:{TraceId} SpanId:{SpanId}", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + stopwatch.Elapsed.TotalMilliseconds, + traceId, + spanId); + } + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/SecurityHeadersMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/SecurityHeadersMiddleware.cs new file mode 100644 index 0000000..febf3dc --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/SecurityHeadersMiddleware.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Http; + +namespace TakeoutSaaS.Shared.Web.Middleware; + +/// +/// 安全响应头中间件 +/// +public sealed class SecurityHeadersMiddleware(RequestDelegate next) +{ + public async Task InvokeAsync(HttpContext context) + { + var headers = context.Response.Headers; + headers["X-Content-Type-Options"] = "nosniff"; + headers["X-Frame-Options"] = "DENY"; + headers["X-XSS-Protection"] = "1; mode=block"; + headers["Referrer-Policy"] = "no-referrer"; + await next(context); + } +} + diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..05a90b2 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs @@ -0,0 +1,28 @@ +using System; +using System.Security.Claims; + +namespace TakeoutSaaS.Shared.Web.Security; + +/// +/// ClaimsPrincipal 便捷扩展 +/// +public static class ClaimsPrincipalExtensions +{ + /// + /// 获取当前用户 Id(不存在时返回 0)。 + /// + public static long GetUserId(this ClaimsPrincipal? principal) + { + if (principal == null) + { + return 0; + } + + var identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier) + ?? principal.FindFirstValue("sub"); + + return long.TryParse(identifier, out var userId) + ? userId + : 0; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs new file mode 100644 index 0000000..e7f5209 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs @@ -0,0 +1,37 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Shared.Web.Security; + +/// +/// 基于 HttpContext 的当前用户访问器。 +/// +/// +/// 初始化访问器。 +/// +public sealed class HttpContextCurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUserAccessor +{ + + + /// + public long UserId + { + get + { + var principal = httpContextAccessor.HttpContext?.User; + if (principal == null || !principal.Identity?.IsAuthenticated == true) + { + return 0; + } + + var identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier) + ?? principal.FindFirstValue("sub"); + + return long.TryParse(identifier, out var id) ? id : 0; + } + } + + /// + public bool IsAuthenticated => UserId != 0; +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/TenantHttpContextExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/TenantHttpContextExtensions.cs new file mode 100644 index 0000000..12832c9 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/TenantHttpContextExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Http; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Shared.Web.Security; + +/// +/// HttpContext 租户扩展方法。 +/// +public static class TenantHttpContextExtensions +{ + /// + /// 获取 HttpContext.Items 中缓存的租户上下文。 + /// + /// 当前 HttpContext + /// 租户上下文,若不存在则返回 null + public static TenantContext? GetTenantContext(this HttpContext? context) + { + if (context == null) + { + return null; + } + + if (context.Items.TryGetValue(TenantConstants.HttpContextItemKey, out var value) && + value is TenantContext tenantContext) + { + return tenantContext; + } + + return null; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs new file mode 100644 index 0000000..e167f00 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace TakeoutSaaS.Shared.Web.Swagger; + +/// +/// 根据 API 版本动态注册 Swagger 文档。 +/// +internal sealed class ConfigureSwaggerOptions( + IApiVersionDescriptionProvider provider, + IOptions settings) : IConfigureOptions +{ + private readonly SwaggerDocumentSettings _settings = settings.Value; + + public void Configure(SwaggerGenOptions options) + { + foreach (var description in provider.ApiVersionDescriptions) + { + var info = new OpenApiInfo + { + Title = $"{_settings.Title} {description.ApiVersion}", + Version = description.ApiVersion.ToString(), + Description = description.IsDeprecated + ? $"{_settings.Description}(该版本已弃用)" + : _settings.Description + }; + + options.SwaggerGeneratorOptions.SwaggerDocs[description.GroupName] = info; + } + + if (_settings.EnableAuthorization) + { + const string bearerSchemeName = "Bearer"; + + var scheme = new OpenApiSecurityScheme + { + Name = "Authorization", + Description = "在下方输入Bearer Token,格式:Bearer {token}", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT" + }; + + options.AddSecurityDefinition(bearerSchemeName, scheme); + options.AddSecurityRequirement(document => + { + var requirement = new OpenApiSecurityRequirement + { + { new OpenApiSecuritySchemeReference(bearerSchemeName, document, null), new List() } + }; + + return requirement; + }); + } + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerDocumentSettings.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerDocumentSettings.cs new file mode 100644 index 0000000..f0410e0 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerDocumentSettings.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Shared.Web.Swagger; + +/// +/// Swagger 文档配置。 +/// +public class SwaggerDocumentSettings +{ + /// + /// 文档标题。 + /// + public string Title { get; set; } = "TakeoutSaaS API"; + + /// + /// 描述信息。 + /// + public string? Description { get; set; } + + /// + /// 是否启用 JWT Authorize 按钮。 + /// + public bool EnableAuthorization { get; set; } = true; +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs new file mode 100644 index 0000000..8b1fb25 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace TakeoutSaaS.Shared.Web.Swagger; + +/// +/// Swagger 注册/启用扩展。 +/// +public static class SwaggerExtensions +{ + /// + /// 注入统一的 Swagger 服务。 + /// + public static IServiceCollection AddSharedSwagger(this IServiceCollection services, Action? configure = null) + { + services.AddSwaggerGen(); + services.AddSingleton(_ => + { + var settings = new SwaggerDocumentSettings(); + configure?.Invoke(settings); + return settings; + }); + services.AddSingleton>(provider => + new ConfigureSwaggerOptions( + provider.GetRequiredService(), + Options.Create(provider.GetRequiredService()))); + + return services; + } + + /// + /// 开发环境启用 Swagger UI(自动注册所有版本)。 + /// + public static IApplicationBuilder UseSharedSwagger(this IApplicationBuilder app) + { + var env = app.ApplicationServices.GetRequiredService(); + if (!env.IsDevelopment()) + { + return app; + } + + var provider = app.ApplicationServices.GetRequiredService(); + var settings = app.ApplicationServices.GetRequiredService(); + + app.UseSwagger(); + app.UseSwaggerUI(options => + { + foreach (var description in provider.ApiVersionDescriptions) + { + options.SwaggerEndpoint( + $"/swagger/{description.GroupName}/swagger.json", + $"{settings.Title} {description.ApiVersion}"); + } + + options.DisplayRequestDuration(); + }); + + return app; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj b/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj new file mode 100644 index 0000000..b387355 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj @@ -0,0 +1,24 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/src/Domain/TakeoutSaaS.Domain/Analytics/Entities/MetricAlertRule.cs b/src/Domain/TakeoutSaaS.Domain/Analytics/Entities/MetricAlertRule.cs new file mode 100644 index 0000000..d5ae3ee --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Analytics/Entities/MetricAlertRule.cs @@ -0,0 +1,35 @@ +using TakeoutSaaS.Domain.Analytics.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Analytics.Entities; + +/// +/// 指标告警规则。 +/// +public sealed class MetricAlertRule : MultiTenantEntityBase +{ + /// + /// 关联指标。 + /// + public long MetricDefinitionId { get; set; } + + /// + /// 触发条件 JSON。 + /// + public string ConditionJson { get; set; } = string.Empty; + + /// + /// 告警级别。 + /// + public MetricAlertSeverity Severity { get; set; } = MetricAlertSeverity.Warning; + + /// + /// 通知渠道。 + /// + public string NotificationChannels { get; set; } = "email"; + + /// + /// 是否启用。 + /// + public bool Enabled { get; set; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Analytics/Entities/MetricDefinition.cs b/src/Domain/TakeoutSaaS.Domain/Analytics/Entities/MetricDefinition.cs new file mode 100644 index 0000000..1180216 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Analytics/Entities/MetricDefinition.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Analytics.Entities; + +/// +/// 指标定义,描述可观测的数据点。 +/// +public sealed class MetricDefinition : MultiTenantEntityBase +{ + /// + /// 指标编码。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 指标名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 说明。 + /// + public string? Description { get; set; } + + /// + /// 维度描述 JSON。 + /// + public string? DimensionsJson { get; set; } + + /// + /// 默认聚合方式。 + /// + public string DefaultAggregation { get; set; } = "sum"; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Analytics/Entities/MetricSnapshot.cs b/src/Domain/TakeoutSaaS.Domain/Analytics/Entities/MetricSnapshot.cs new file mode 100644 index 0000000..234b0a0 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Analytics/Entities/MetricSnapshot.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Analytics.Entities; + +/// +/// 指标快照,用于大盘展示。 +/// +public sealed class MetricSnapshot : MultiTenantEntityBase +{ + /// + /// 指标定义 ID。 + /// + public long MetricDefinitionId { get; set; } + + /// + /// 维度键(JSON)。 + /// + public string DimensionKey { get; set; } = string.Empty; + + /// + /// 统计时间窗口开始。 + /// + public DateTime WindowStart { get; set; } + + /// + /// 统计时间窗口结束。 + /// + public DateTime WindowEnd { get; set; } + + /// + /// 数值。 + /// + public decimal Value { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Analytics/Enums/MetricAlertSeverity.cs b/src/Domain/TakeoutSaaS.Domain/Analytics/Enums/MetricAlertSeverity.cs new file mode 100644 index 0000000..a1e79eb --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Analytics/Enums/MetricAlertSeverity.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Analytics.Enums; + +/// +/// 指标告警严重程度。 +/// +public enum MetricAlertSeverity +{ + /// + /// 信息提示。 + /// + Info = 0, + + /// + /// 告警。 + /// + Warning = 1, + + /// + /// 严重。 + /// + Critical = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/Coupon.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/Coupon.cs new file mode 100644 index 0000000..7c375e1 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/Coupon.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Domain.Coupons.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Coupons.Entities; + +/// +/// 用户领取的券。 +/// +public sealed class Coupon : MultiTenantEntityBase +{ + /// + /// 模板标识。 + /// + public long CouponTemplateId { get; set; } + + /// + /// 券码或序列号。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 归属用户。 + /// + public long UserId { get; set; } + + /// + /// 订单 ID(已使用时记录)。 + /// + public long? OrderId { get; set; } + + /// + /// 状态。 + /// + public CouponStatus Status { get; set; } = CouponStatus.Available; + + /// + /// 发放时间。 + /// + public DateTime IssuedAt { get; set; } = DateTime.UtcNow; + + /// + /// 使用时间。 + /// + public DateTime? UsedAt { get; set; } + + /// + /// 到期时间。 + /// + public DateTime ExpireAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/CouponTemplate.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/CouponTemplate.cs new file mode 100644 index 0000000..8f795a6 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/CouponTemplate.cs @@ -0,0 +1,90 @@ +using TakeoutSaaS.Domain.Coupons.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Coupons.Entities; + +/// +/// 优惠券模板。 +/// +public sealed class CouponTemplate : MultiTenantEntityBase +{ + /// + /// 模板名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 券类型。 + /// + public CouponType CouponType { get; set; } = CouponType.AmountOff; + + /// + /// 面值或折扣额度。 + /// + public decimal Value { get; set; } + + /// + /// 折扣上限(针对折扣券)。 + /// + public decimal? DiscountCap { get; set; } + + /// + /// 最低消费门槛。 + /// + public decimal? MinimumSpend { get; set; } + + /// + /// 可用开始时间。 + /// + public DateTime? ValidFrom { get; set; } + + /// + /// 可用结束时间。 + /// + public DateTime? ValidTo { get; set; } + + /// + /// 有效天数(相对发放时间)。 + /// + public int? RelativeValidDays { get; set; } + + /// + /// 总发放数量上限。 + /// + public int? TotalQuantity { get; set; } + + /// + /// 已领取数量。 + /// + public int ClaimedQuantity { get; set; } + + /// + /// 适用门店 ID 集合(JSON)。 + /// + public string? StoreScopeJson { get; set; } + + /// + /// 适用品类或商品范围(JSON)。 + /// + public string? ProductScopeJson { get; set; } + + /// + /// 发放渠道(JSON)。 + /// + public string? ChannelsJson { get; set; } + + /// + /// 是否允许叠加其他优惠。 + /// + public bool AllowStack { get; set; } + + /// + /// 状态。 + /// + public CouponTemplateStatus Status { get; set; } = CouponTemplateStatus.Draft; + + /// + /// 备注。 + /// + public string? Description { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/PromotionCampaign.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/PromotionCampaign.cs new file mode 100644 index 0000000..8cab151 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Entities/PromotionCampaign.cs @@ -0,0 +1,55 @@ +using TakeoutSaaS.Domain.Coupons.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Coupons.Entities; + +/// +/// 营销活动配置。 +/// +public sealed class PromotionCampaign : MultiTenantEntityBase +{ + /// + /// 活动名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 活动类型。 + /// + public PromotionType PromotionType { get; set; } = PromotionType.Coupon; + + /// + /// 活动状态。 + /// + public PromotionStatus Status { get; set; } = PromotionStatus.Draft; + + /// + /// 开始时间。 + /// + public DateTime StartAt { get; set; } + + /// + /// 结束时间。 + /// + public DateTime EndAt { get; set; } + + /// + /// 预算金额。 + /// + public decimal? Budget { get; set; } + + /// + /// 活动规则 JSON。 + /// + public string RulesJson { get; set; } = string.Empty; + + /// + /// 目标人群描述。 + /// + public string? AudienceDescription { get; set; } + + /// + /// 营销素材(如 banner)。 + /// + public string? BannerUrl { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/CouponStatus.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/CouponStatus.cs new file mode 100644 index 0000000..7897089 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/CouponStatus.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Coupons.Enums; + +/// +/// 券使用状态。 +/// +public enum CouponStatus +{ + /// + /// 可使用。 + /// + Available = 0, + + /// + /// 已锁定。 + /// + Locked = 1, + + /// + /// 已使用。 + /// + Redeemed = 2, + + /// + /// 已过期。 + /// + Expired = 3, + + /// + /// 已作废。 + /// + Voided = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/CouponTemplateStatus.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/CouponTemplateStatus.cs new file mode 100644 index 0000000..d5aefd5 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/CouponTemplateStatus.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Coupons.Enums; + +/// +/// 券模板状态。 +/// +public enum CouponTemplateStatus +{ + /// + /// 草稿状态。 + /// + Draft = 0, + + /// + /// 已上线可发放。 + /// + Active = 1, + + /// + /// 已下架。 + /// + Archived = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/CouponType.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/CouponType.cs new file mode 100644 index 0000000..8206bd2 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/CouponType.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Coupons.Enums; + +/// +/// 券类型。 +/// +public enum CouponType +{ + /// + /// 满减券。 + /// + AmountOff = 0, + + /// + /// 折扣券。 + /// + Percentage = 1, + + /// + /// 现金券/无门槛券。 + /// + Cash = 2, + + /// + /// 免配送费券。 + /// + DeliveryFee = 3, + + /// + /// 礼品/兑换券。 + /// + Gift = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PromotionStatus.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PromotionStatus.cs new file mode 100644 index 0000000..2b12ee1 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PromotionStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Coupons.Enums; + +/// +/// 营销活动状态。 +/// +public enum PromotionStatus +{ + /// + /// 草稿。 + /// + Draft = 0, + + /// + /// 进行中。 + /// + Active = 1, + + /// + /// 已结束。 + /// + Completed = 2, + + /// + /// 暂停。 + /// + Paused = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PromotionType.cs b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PromotionType.cs new file mode 100644 index 0000000..e30893d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Coupons/Enums/PromotionType.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Coupons.Enums; + +/// +/// 营销活动类型。 +/// +public enum PromotionType +{ + /// + /// 优惠券活动。 + /// + Coupon = 0, + + /// + /// 秒杀/限时购。 + /// + FlashSale = 1, + + /// + /// 满减活动。 + /// + FullReduction = 2, + + /// + /// 抽奖活动。 + /// + Lottery = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/CustomerService/Entities/ChatMessage.cs b/src/Domain/TakeoutSaaS.Domain/CustomerService/Entities/ChatMessage.cs new file mode 100644 index 0000000..ba0d3e6 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/CustomerService/Entities/ChatMessage.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.CustomerService.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.CustomerService.Entities; + +/// +/// 会话消息。 +/// +public sealed class ChatMessage : MultiTenantEntityBase +{ + /// + /// 会话标识。 + /// + public long ChatSessionId { get; set; } + + /// + /// 发送方类型。 + /// + public MessageSenderType SenderType { get; set; } = MessageSenderType.Customer; + + /// + /// 发送方用户 ID。 + /// + public long? SenderUserId { get; set; } + + /// + /// 消息内容。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 消息类型(文字/图片/语音等)。 + /// + public string ContentType { get; set; } = "text/plain"; + + /// + /// 是否已读。 + /// + public bool IsRead { get; set; } + + /// + /// 读取时间。 + /// + public DateTime? ReadAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/CustomerService/Entities/ChatSession.cs b/src/Domain/TakeoutSaaS.Domain/CustomerService/Entities/ChatSession.cs new file mode 100644 index 0000000..d9ea66d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/CustomerService/Entities/ChatSession.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Domain.CustomerService.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.CustomerService.Entities; + +/// +/// 客服会话。 +/// +public sealed class ChatSession : MultiTenantEntityBase +{ + /// + /// 会话编号。 + /// + public string SessionCode { get; set; } = string.Empty; + + /// + /// 顾客用户 ID。 + /// + public long CustomerUserId { get; set; } + + /// + /// 当前客服员工 ID。 + /// + public long? AgentUserId { get; set; } + + /// + /// 所属门店(可空为平台)。 + /// + public long? StoreId { get; set; } + + /// + /// 会话状态。 + /// + public ChatSessionStatus Status { get; set; } = ChatSessionStatus.Waiting; + + /// + /// 是否机器人接待中。 + /// + public bool IsBotActive { get; set; } + + /// + /// 开始时间。 + /// + public DateTime StartedAt { get; set; } = DateTime.UtcNow; + + /// + /// 结束时间。 + /// + public DateTime? EndedAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/CustomerService/Entities/SupportTicket.cs b/src/Domain/TakeoutSaaS.Domain/CustomerService/Entities/SupportTicket.cs new file mode 100644 index 0000000..39c5ae4 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/CustomerService/Entities/SupportTicket.cs @@ -0,0 +1,55 @@ +using TakeoutSaaS.Domain.CustomerService.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.CustomerService.Entities; + +/// +/// 客服工单。 +/// +public sealed class SupportTicket : MultiTenantEntityBase +{ + /// + /// 工单编号。 + /// + public string TicketNo { get; set; } = string.Empty; + + /// + /// 客户用户 ID。 + /// + public long CustomerUserId { get; set; } + + /// + /// 关联订单(如有)。 + /// + public long? OrderId { get; set; } + + /// + /// 工单主题。 + /// + public string Subject { get; set; } = string.Empty; + + /// + /// 工单详情。 + /// + public string Description { get; set; } = string.Empty; + + /// + /// 优先级。 + /// + public TicketPriority Priority { get; set; } = TicketPriority.Normal; + + /// + /// 状态。 + /// + public TicketStatus Status { get; set; } = TicketStatus.Open; + + /// + /// 指派的客服。 + /// + public long? AssignedAgentId { get; set; } + + /// + /// 关闭时间。 + /// + public DateTime? ClosedAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/CustomerService/Entities/TicketComment.cs b/src/Domain/TakeoutSaaS.Domain/CustomerService/Entities/TicketComment.cs new file mode 100644 index 0000000..fd24969 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/CustomerService/Entities/TicketComment.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.CustomerService.Entities; + +/// +/// 工单评论/流转记录。 +/// +public sealed class TicketComment : MultiTenantEntityBase +{ + /// + /// 工单标识。 + /// + public long SupportTicketId { get; set; } + + /// + /// 评论人 ID。 + /// + public long? AuthorUserId { get; set; } + + /// + /// 评论内容。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 是否内部备注。 + /// + public bool IsInternal { get; set; } + + /// + /// 附件 JSON。 + /// + public string? AttachmentsJson { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/CustomerService/Enums/ChatSessionStatus.cs b/src/Domain/TakeoutSaaS.Domain/CustomerService/Enums/ChatSessionStatus.cs new file mode 100644 index 0000000..e774652 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/CustomerService/Enums/ChatSessionStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.CustomerService.Enums; + +/// +/// 客服会话状态。 +/// +public enum ChatSessionStatus +{ + /// + /// 等待客服接入。 + /// + Waiting = 0, + + /// + /// 聊天进行中。 + /// + Active = 1, + + /// + /// 已转人工排队。 + /// + Queueing = 2, + + /// + /// 已结束。 + /// + Closed = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/CustomerService/Enums/MessageSenderType.cs b/src/Domain/TakeoutSaaS.Domain/CustomerService/Enums/MessageSenderType.cs new file mode 100644 index 0000000..826c923 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/CustomerService/Enums/MessageSenderType.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.CustomerService.Enums; + +/// +/// 消息发送方类型。 +/// +public enum MessageSenderType +{ + /// + /// 顾客。 + /// + Customer = 0, + + /// + /// 客服人员。 + /// + Agent = 1, + + /// + /// 机器人。 + /// + Bot = 2, + + /// + /// 系统通知。 + /// + System = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/CustomerService/Enums/TicketPriority.cs b/src/Domain/TakeoutSaaS.Domain/CustomerService/Enums/TicketPriority.cs new file mode 100644 index 0000000..e875355 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/CustomerService/Enums/TicketPriority.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.CustomerService.Enums; + +/// +/// 工单优先级。 +/// +public enum TicketPriority +{ + /// + /// 普通。 + /// + Normal = 0, + + /// + /// 高。 + /// + High = 1, + + /// + /// 紧急。 + /// + Urgent = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/CustomerService/Enums/TicketStatus.cs b/src/Domain/TakeoutSaaS.Domain/CustomerService/Enums/TicketStatus.cs new file mode 100644 index 0000000..40d6311 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/CustomerService/Enums/TicketStatus.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.CustomerService.Enums; + +/// +/// 工单状态。 +/// +public enum TicketStatus +{ + /// + /// 新建未处理。 + /// + Open = 0, + + /// + /// 处理中。 + /// + InProgress = 1, + + /// + /// 等待客户反馈。 + /// + PendingCustomer = 2, + + /// + /// 已解决。 + /// + Resolved = 3, + + /// + /// 已关闭。 + /// + Closed = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryEvent.cs b/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryEvent.cs new file mode 100644 index 0000000..a10fd61 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryEvent.cs @@ -0,0 +1,35 @@ +using TakeoutSaaS.Domain.Deliveries.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Deliveries.Entities; + +/// +/// 配送状态事件流水。 +/// +public sealed class DeliveryEvent : MultiTenantEntityBase +{ + /// + /// 配送单标识。 + /// + public long DeliveryOrderId { get; set; } + + /// + /// 事件类型。 + /// + public DeliveryEventType EventType { get; set; } = DeliveryEventType.Updated; + + /// + /// 事件描述。 + /// + public string Message { get; set; } = string.Empty; + + /// + /// 原始数据 JSON。 + /// + public string? Payload { get; set; } + + /// + /// 发生时间。 + /// + public DateTime OccurredAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryOrder.cs b/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryOrder.cs new file mode 100644 index 0000000..c547d6b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryOrder.cs @@ -0,0 +1,62 @@ +using TakeoutSaaS.Domain.Deliveries.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Deliveries.Entities; + +/// +/// 配送单。 +/// +public sealed class DeliveryOrder : MultiTenantEntityBase +{ + public long OrderId { get; set; } + + /// + /// 配送服务商。 + /// + public DeliveryProvider Provider { get; set; } = DeliveryProvider.InHouse; + + /// + /// 第三方配送单号。 + /// + public string? ProviderOrderId { get; set; } + + /// + /// 状态。 + /// + public DeliveryStatus Status { get; set; } = DeliveryStatus.Pending; + + /// + /// 配送费。 + /// + public decimal? DeliveryFee { get; set; } + + /// + /// 骑手姓名。 + /// + public string? CourierName { get; set; } + + /// + /// 骑手电话。 + /// + public string? CourierPhone { get; set; } + + /// + /// 下发时间。 + /// + public DateTime? DispatchedAt { get; set; } + + /// + /// 取餐时间。 + /// + public DateTime? PickedUpAt { get; set; } + + /// + /// 完成时间。 + /// + public DateTime? DeliveredAt { get; set; } + + /// + /// 异常原因。 + /// + public string? FailureReason { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Deliveries/Enums/DeliveryEventType.cs b/src/Domain/TakeoutSaaS.Domain/Deliveries/Enums/DeliveryEventType.cs new file mode 100644 index 0000000..ce1914d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Deliveries/Enums/DeliveryEventType.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Deliveries.Enums; + +/// +/// 配送事件类型。 +/// +public enum DeliveryEventType +{ + /// + /// 状态更新。 + /// + Updated = 0, + + /// + /// 渠道回调。 + /// + Callback = 1, + + /// + /// 加价或异常通知。 + /// + Exception = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Deliveries/Enums/DeliveryProvider.cs b/src/Domain/TakeoutSaaS.Domain/Deliveries/Enums/DeliveryProvider.cs new file mode 100644 index 0000000..7957eb7 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Deliveries/Enums/DeliveryProvider.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Domain.Deliveries.Enums; + +/// +/// 配送服务商类型。 +/// +public enum DeliveryProvider +{ + /// + /// 自建配送团队。 + /// + InHouse = 0, + + /// + /// 达达。 + /// + Dada = 1, + + /// + /// 闪送。 + /// + FlashEx = 2, + + /// + /// 美团配送。 + /// + Meituan = 3, + + /// + /// 饿了么配送。 + /// + Eleme = 4, + + /// + /// 顺丰同城。 + /// + Shunfeng = 5 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Deliveries/Enums/DeliveryStatus.cs b/src/Domain/TakeoutSaaS.Domain/Deliveries/Enums/DeliveryStatus.cs new file mode 100644 index 0000000..1d2ae38 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Deliveries/Enums/DeliveryStatus.cs @@ -0,0 +1,42 @@ +namespace TakeoutSaaS.Domain.Deliveries.Enums; + +/// +/// 配送状态。 +/// +public enum DeliveryStatus +{ + /// + /// 待接单。 + /// + Pending = 0, + + /// + /// 骑手已接单。 + /// + Accepted = 1, + + /// + /// 正在取餐。 + /// + PickingUp = 2, + + /// + /// 配送途中。 + /// + Delivering = 3, + + /// + /// 已送达完成。 + /// + Completed = 4, + + /// + /// 被取消。 + /// + Cancelled = 5, + + /// + /// 配送失败。 + /// + Failed = 6 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Deliveries/Repositories/IDeliveryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Deliveries/Repositories/IDeliveryRepository.cs new file mode 100644 index 0000000..27d5c95 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Deliveries/Repositories/IDeliveryRepository.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Deliveries.Entities; +using TakeoutSaaS.Domain.Deliveries.Enums; + +namespace TakeoutSaaS.Domain.Deliveries.Repositories; + +/// +/// 配送聚合仓储契约。 +/// +public interface IDeliveryRepository +{ + /// + /// 依据标识获取配送单。 + /// + Task FindByIdAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 依据订单标识获取配送单。 + /// + Task FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取配送事件轨迹。 + /// + Task> GetEventsAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增配送单。 + /// + Task AddDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default); + + /// + /// 新增配送事件。 + /// + Task AddEventAsync(DeliveryEvent deliveryEvent, CancellationToken cancellationToken = default); + + /// + /// 持久化变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// 按状态查询配送单。 + /// + Task> SearchAsync(long tenantId, DeliveryStatus? status, long? orderId, CancellationToken cancellationToken = default); + + /// + /// 更新配送单。 + /// + Task UpdateDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default); + + /// + /// 删除配送单及事件。 + /// + Task DeleteDeliveryOrderAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs new file mode 100644 index 0000000..68694b0 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Dictionary.Entities; + +/// +/// 参数字典分组(系统参数、业务参数)。 +/// +public sealed class DictionaryGroup : MultiTenantEntityBase +{ + /// + /// 分组编码(唯一)。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 分组名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 分组作用域:系统/业务。 + /// + public DictionaryScope Scope { get; set; } = DictionaryScope.Business; + + /// + /// 描述信息。 + /// + public string? Description { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 字典项集合。 + /// + public ICollection Items { get; set; } = new List(); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs new file mode 100644 index 0000000..1a38aba --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryItem.cs @@ -0,0 +1,49 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Dictionary.Entities; + +/// +/// 参数字典项。 +/// +public sealed class DictionaryItem : MultiTenantEntityBase +{ + /// + /// 关联分组 ID。 + /// + public long GroupId { get; set; } + + /// + /// 字典项键。 + /// + public string Key { get; set; } = string.Empty; + + /// + /// 字典项值。 + /// + public string Value { get; set; } = string.Empty; + + /// + /// 是否默认项。 + /// + public bool IsDefault { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 排序值,越小越靠前。 + /// + public int SortOrder { get; set; } + + /// + /// 描述信息。 + /// + public string? Description { get; set; } + + /// + /// 导航属性:所属分组。 + /// + public DictionaryGroup? Group { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/DictionaryScope.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/DictionaryScope.cs new file mode 100644 index 0000000..5e27e54 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/DictionaryScope.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Dictionary.Enums; + +/// +/// 参数字典作用域。 +/// +public enum DictionaryScope +{ + /// + /// 系统级参数,所有租户共享。 + /// + System = 1, + + /// + /// 业务级参数,仅当前租户可见。 + /// + Business = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs new file mode 100644 index 0000000..9a9b427 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.Dictionary.Enums; + +namespace TakeoutSaaS.Domain.Dictionary.Repositories; + +/// +/// 参数字典仓储契约。 +/// +public interface IDictionaryRepository +{ + /// + /// 依据 ID 获取分组。 + /// + Task FindGroupByIdAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 依据编码获取分组。 + /// + Task FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default); + + /// + /// 搜索分组,可按作用域过滤。 + /// + Task> SearchGroupsAsync(DictionaryScope? scope, CancellationToken cancellationToken = default); + + /// + /// 新增分组。 + /// + Task AddGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default); + + /// + /// 删除分组。 + /// + Task RemoveGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default); + + /// + /// 依据 ID 获取字典项。 + /// + Task FindItemByIdAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 获取某分组下的所有字典项。 + /// + Task> GetItemsByGroupIdAsync(long groupId, CancellationToken cancellationToken = default); + + /// + /// 按分组编码集合获取字典项(可包含系统参数)。 + /// + Task> GetItemsByCodesAsync(IEnumerable codes, long tenantId, bool includeSystem, CancellationToken cancellationToken = default); + + /// + /// 新增字典项。 + /// + Task AddItemAsync(DictionaryItem item, CancellationToken cancellationToken = default); + + /// + /// 删除字典项。 + /// + Task RemoveItemAsync(DictionaryItem item, CancellationToken cancellationToken = default); + + /// + /// 持久化更改。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Distribution/Entities/AffiliateOrder.cs b/src/Domain/TakeoutSaaS.Domain/Distribution/Entities/AffiliateOrder.cs new file mode 100644 index 0000000..856f922 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Distribution/Entities/AffiliateOrder.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Distribution.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Distribution.Entities; + +/// +/// 分销订单记录。 +/// +public sealed class AffiliateOrder : MultiTenantEntityBase +{ + /// + /// 推广人标识。 + /// + public long AffiliatePartnerId { get; set; } + + /// + /// 关联订单。 + /// + public long OrderId { get; set; } + + /// + /// 用户 ID。 + /// + public long BuyerUserId { get; set; } + + /// + /// 订单金额。 + /// + public decimal OrderAmount { get; set; } + + /// + /// 预计佣金。 + /// + public decimal EstimatedCommission { get; set; } + + /// + /// 当前状态。 + /// + public AffiliateOrderStatus Status { get; set; } = AffiliateOrderStatus.Pending; + + /// + /// 结算完成时间。 + /// + public DateTime? SettledAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Distribution/Entities/AffiliatePartner.cs b/src/Domain/TakeoutSaaS.Domain/Distribution/Entities/AffiliatePartner.cs new file mode 100644 index 0000000..551e39d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Distribution/Entities/AffiliatePartner.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Distribution.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Distribution.Entities; + +/// +/// 分销/推广合作伙伴。 +/// +public sealed class AffiliatePartner : MultiTenantEntityBase +{ + /// + /// 用户 ID(如绑定平台账号)。 + /// + public long? UserId { get; set; } + + /// + /// 昵称或渠道名称。 + /// + public string DisplayName { get; set; } = string.Empty; + + /// + /// 联系电话。 + /// + public string? Phone { get; set; } + + /// + /// 渠道类型。 + /// + public AffiliateChannelType ChannelType { get; set; } = AffiliateChannelType.Personal; + + /// + /// 分成比例(0-1)。 + /// + public decimal CommissionRate { get; set; } + + /// + /// 当前状态。 + /// + public AffiliateStatus Status { get; set; } = AffiliateStatus.Pending; + + /// + /// 审核备注。 + /// + public string? Remarks { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Distribution/Entities/AffiliatePayout.cs b/src/Domain/TakeoutSaaS.Domain/Distribution/Entities/AffiliatePayout.cs new file mode 100644 index 0000000..c9955f9 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Distribution/Entities/AffiliatePayout.cs @@ -0,0 +1,40 @@ +using TakeoutSaaS.Domain.Distribution.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Distribution.Entities; + +/// +/// 佣金结算记录。 +/// +public sealed class AffiliatePayout : MultiTenantEntityBase +{ + /// + /// 合作伙伴标识。 + /// + public long AffiliatePartnerId { get; set; } + + /// + /// 结算周期描述。 + /// + public string Period { get; set; } = string.Empty; + + /// + /// 结算金额。 + /// + public decimal Amount { get; set; } + + /// + /// 状态。 + /// + public PayoutStatus Status { get; set; } = PayoutStatus.Pending; + + /// + /// 打款时间。 + /// + public DateTime? PaidAt { get; set; } + + /// + /// 备注。 + /// + public string? Remarks { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Distribution/Enums/AffiliateChannelType.cs b/src/Domain/TakeoutSaaS.Domain/Distribution/Enums/AffiliateChannelType.cs new file mode 100644 index 0000000..963bd98 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Distribution/Enums/AffiliateChannelType.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Distribution.Enums; + +/// +/// 推广渠道类型。 +/// +public enum AffiliateChannelType +{ + /// + /// 个人。 + /// + Personal = 0, + + /// + /// 自媒体。 + /// + Media = 1, + + /// + /// 门店地推。 + /// + Offline = 2, + + /// + /// 第三方联盟。 + /// + Alliance = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Distribution/Enums/AffiliateOrderStatus.cs b/src/Domain/TakeoutSaaS.Domain/Distribution/Enums/AffiliateOrderStatus.cs new file mode 100644 index 0000000..a28f953 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Distribution/Enums/AffiliateOrderStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Distribution.Enums; + +/// +/// 分销订单状态。 +/// +public enum AffiliateOrderStatus +{ + /// + /// 待成交通知。 + /// + Pending = 0, + + /// + /// 已完成待结算。 + /// + AwaitingPayout = 1, + + /// + /// 已结算。 + /// + Settled = 2, + + /// + /// 因退款失效。 + /// + Invalidated = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Distribution/Enums/AffiliateStatus.cs b/src/Domain/TakeoutSaaS.Domain/Distribution/Enums/AffiliateStatus.cs new file mode 100644 index 0000000..7e25a50 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Distribution/Enums/AffiliateStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Distribution.Enums; + +/// +/// 合作伙伴状态。 +/// +public enum AffiliateStatus +{ + /// + /// 待审核。 + /// + Pending = 0, + + /// + /// 已激活。 + /// + Active = 1, + + /// + /// 已冻结。 + /// + Suspended = 2, + + /// + /// 已退出。 + /// + Closed = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Distribution/Enums/PayoutStatus.cs b/src/Domain/TakeoutSaaS.Domain/Distribution/Enums/PayoutStatus.cs new file mode 100644 index 0000000..0f96e3d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Distribution/Enums/PayoutStatus.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Distribution.Enums; + +/// +/// 佣金结算状态。 +/// +public enum PayoutStatus +{ + /// + /// 待打款。 + /// + Pending = 0, + + /// + /// 已打款。 + /// + Paid = 1, + + /// + /// 已驳回。 + /// + Rejected = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CheckInCampaign.cs b/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CheckInCampaign.cs new file mode 100644 index 0000000..1e279d5 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CheckInCampaign.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Engagement.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Engagement.Entities; + +/// +/// 签到活动配置。 +/// +public sealed class CheckInCampaign : MultiTenantEntityBase +{ + /// + /// 活动名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 活动描述。 + /// + public string? Description { get; set; } + + /// + /// 开始日期。 + /// + public DateTime StartDate { get; set; } + + /// + /// 结束日期。 + /// + public DateTime EndDate { get; set; } + + /// + /// 支持补签次数。 + /// + public int AllowMakeupCount { get; set; } + + /// + /// 连签奖励 JSON。 + /// + public string RewardsJson { get; set; } = string.Empty; + + /// + /// 状态。 + /// + public CheckInCampaignStatus Status { get; set; } = CheckInCampaignStatus.Draft; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CheckInRecord.cs b/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CheckInRecord.cs new file mode 100644 index 0000000..011d048 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CheckInRecord.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Engagement.Entities; + +/// +/// 用户签到记录。 +/// +public sealed class CheckInRecord : MultiTenantEntityBase +{ + /// + /// 活动标识。 + /// + public long CheckInCampaignId { get; set; } + + /// + /// 用户标识。 + /// + public long UserId { get; set; } + + /// + /// 签到日期(本地)。 + /// + public DateTime CheckInDate { get; set; } + + /// + /// 是否补签。 + /// + public bool IsMakeup { get; set; } + + /// + /// 获得奖励 JSON。 + /// + public string RewardJson { get; set; } = string.Empty; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CommunityComment.cs b/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CommunityComment.cs new file mode 100644 index 0000000..f158121 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CommunityComment.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Engagement.Entities; + +/// +/// 社区评论。 +/// +public sealed class CommunityComment : MultiTenantEntityBase +{ + /// + /// 动态标识。 + /// + public long PostId { get; set; } + + /// + /// 评论人。 + /// + public long AuthorUserId { get; set; } + + /// + /// 评论内容。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 父级评论 ID。 + /// + public long? ParentId { get; set; } + + /// + /// 状态。 + /// + public bool IsDeleted { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CommunityPost.cs b/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CommunityPost.cs new file mode 100644 index 0000000..6b77d38 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CommunityPost.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Engagement.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Engagement.Entities; + +/// +/// 社区动态。 +/// +public sealed class CommunityPost : MultiTenantEntityBase +{ + /// + /// 作者用户 ID。 + /// + public long AuthorUserId { get; set; } + + /// + /// 标题。 + /// + public string? Title { get; set; } + + /// + /// 内容。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 媒体资源 JSON。 + /// + public string? MediaJson { get; set; } + + /// + /// 状态。 + /// + public PostStatus Status { get; set; } = PostStatus.PendingReview; + + /// + /// 点赞数。 + /// + public int LikeCount { get; set; } + + /// + /// 评论数。 + /// + public int CommentCount { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CommunityReaction.cs b/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CommunityReaction.cs new file mode 100644 index 0000000..4d1dbdc --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Engagement/Entities/CommunityReaction.cs @@ -0,0 +1,30 @@ +using TakeoutSaaS.Domain.Engagement.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Engagement.Entities; + +/// +/// 社区互动反馈。 +/// +public sealed class CommunityReaction : MultiTenantEntityBase +{ + /// + /// 动态 ID。 + /// + public long PostId { get; set; } + + /// + /// 用户 ID。 + /// + public long UserId { get; set; } + + /// + /// 反应类型。 + /// + public ReactionType ReactionType { get; set; } = ReactionType.Like; + + /// + /// 时间戳。 + /// + public DateTime ReactedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Engagement/Enums/CheckInCampaignStatus.cs b/src/Domain/TakeoutSaaS.Domain/Engagement/Enums/CheckInCampaignStatus.cs new file mode 100644 index 0000000..57a3f0c --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Engagement/Enums/CheckInCampaignStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Engagement.Enums; + +/// +/// 签到活动状态。 +/// +public enum CheckInCampaignStatus +{ + /// + /// 草稿。 + /// + Draft = 0, + + /// + /// 进行中。 + /// + Active = 1, + + /// + /// 已结束。 + /// + Completed = 2, + + /// + /// 已停用。 + /// + Disabled = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Engagement/Enums/PostStatus.cs b/src/Domain/TakeoutSaaS.Domain/Engagement/Enums/PostStatus.cs new file mode 100644 index 0000000..9ce168b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Engagement/Enums/PostStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Engagement.Enums; + +/// +/// 社区动态状态。 +/// +public enum PostStatus +{ + /// + /// 待审核。 + /// + PendingReview = 0, + + /// + /// 已发布。 + /// + Published = 1, + + /// + /// 已屏蔽。 + /// + Blocked = 2, + + /// + /// 已删除。 + /// + Deleted = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Engagement/Enums/ReactionType.cs b/src/Domain/TakeoutSaaS.Domain/Engagement/Enums/ReactionType.cs new file mode 100644 index 0000000..7328bf7 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Engagement/Enums/ReactionType.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Engagement.Enums; + +/// +/// 互动类型。 +/// +public enum ReactionType +{ + /// + /// 点赞。 + /// + Like = 0, + + /// + /// 收藏。 + /// + Favorite = 1, + + /// + /// 点踩。 + /// + Dislike = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/GroupBuying/Entities/GroupOrder.cs b/src/Domain/TakeoutSaaS.Domain/GroupBuying/Entities/GroupOrder.cs new file mode 100644 index 0000000..86d31e3 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/GroupBuying/Entities/GroupOrder.cs @@ -0,0 +1,70 @@ +using TakeoutSaaS.Domain.GroupBuying.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.GroupBuying.Entities; + +/// +/// 拼单活动。 +/// +public sealed class GroupOrder : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 关联商品或套餐。 + /// + public long ProductId { get; set; } + + /// + /// 拼单编号。 + /// + public string GroupOrderNo { get; set; } = string.Empty; + + /// + /// 团长用户 ID。 + /// + public long LeaderUserId { get; set; } + + /// + /// 成团需要的人数。 + /// + public int TargetCount { get; set; } + + /// + /// 当前已参与人数。 + /// + public int CurrentCount { get; set; } + + /// + /// 拼团价格。 + /// + public decimal GroupPrice { get; set; } + + /// + /// 开始时间。 + /// + public DateTime StartAt { get; set; } + + /// + /// 结束时间。 + /// + public DateTime EndAt { get; set; } + + /// + /// 拼团状态。 + /// + public GroupOrderStatus Status { get; set; } = GroupOrderStatus.Open; + + /// + /// 成团时间。 + /// + public DateTime? SucceededAt { get; set; } + + /// + /// 取消时间。 + /// + public DateTime? CancelledAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/GroupBuying/Entities/GroupParticipant.cs b/src/Domain/TakeoutSaaS.Domain/GroupBuying/Entities/GroupParticipant.cs new file mode 100644 index 0000000..943ab14 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/GroupBuying/Entities/GroupParticipant.cs @@ -0,0 +1,35 @@ +using TakeoutSaaS.Domain.GroupBuying.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.GroupBuying.Entities; + +/// +/// 拼单参与者。 +/// +public sealed class GroupParticipant : MultiTenantEntityBase +{ + /// + /// 拼单活动标识。 + /// + public long GroupOrderId { get; set; } + + /// + /// 对应订单标识。 + /// + public long OrderId { get; set; } + + /// + /// 用户标识。 + /// + public long UserId { get; set; } + + /// + /// 参与状态。 + /// + public GroupParticipantStatus Status { get; set; } = GroupParticipantStatus.Joined; + + /// + /// 参与时间。 + /// + public DateTime JoinedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/GroupBuying/Enums/GroupOrderStatus.cs b/src/Domain/TakeoutSaaS.Domain/GroupBuying/Enums/GroupOrderStatus.cs new file mode 100644 index 0000000..b5969fd --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/GroupBuying/Enums/GroupOrderStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.GroupBuying.Enums; + +/// +/// 拼单状态。 +/// +public enum GroupOrderStatus +{ + /// + /// 开放中。 + /// + Open = 0, + + /// + /// 已成团。 + /// + Succeeded = 1, + + /// + /// 已取消。 + /// + Cancelled = 2, + + /// + /// 超时失败。 + /// + Failed = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/GroupBuying/Enums/GroupParticipantStatus.cs b/src/Domain/TakeoutSaaS.Domain/GroupBuying/Enums/GroupParticipantStatus.cs new file mode 100644 index 0000000..7b5f807 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/GroupBuying/Enums/GroupParticipantStatus.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.GroupBuying.Enums; + +/// +/// 拼单参与者状态。 +/// +public enum GroupParticipantStatus +{ + /// + /// 已参团。 + /// + Joined = 0, + + /// + /// 因退款或取消退出。 + /// + Exited = 1, + + /// + /// 团失败待退款。 + /// + PendingRefund = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs new file mode 100644 index 0000000..2bed359 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 管理后台账户实体(平台管理员、租户管理员或商户员工)。 +/// +public sealed class IdentityUser : MultiTenantEntityBase +{ + /// + /// 登录账号。 + /// + public string Account { get; set; } = string.Empty; + + /// + /// 展示名称。 + /// + public string DisplayName { get; set; } = string.Empty; + + /// + /// 密码哈希。 + /// + public string PasswordHash { get; set; } = string.Empty; + + /// + /// 所属商户(平台管理员为空)。 + /// + public long? MerchantId { get; set; } + + /// + /// 头像地址。 + /// + public string? Avatar { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs new file mode 100644 index 0000000..bc2ce45 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/MiniUser.cs @@ -0,0 +1,29 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 小程序用户实体。 +/// +public sealed class MiniUser : MultiTenantEntityBase +{ + /// + /// 微信 OpenId。 + /// + public string OpenId { get; set; } = string.Empty; + + /// + /// 微信 UnionId,可能为空。 + /// + public string? UnionId { get; set; } + + /// + /// 昵称。 + /// + public string Nickname { get; set; } = string.Empty; + + /// + /// 头像地址。 + /// + public string? Avatar { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/Permission.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/Permission.cs new file mode 100644 index 0000000..53a4d0b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/Permission.cs @@ -0,0 +1,24 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 权限定义。 +/// +public sealed class Permission : MultiTenantEntityBase +{ + /// + /// 权限名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 权限编码(租户内唯一)。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 描述。 + /// + public string? Description { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/Role.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/Role.cs new file mode 100644 index 0000000..356d8bc --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/Role.cs @@ -0,0 +1,24 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 角色定义。 +/// +public sealed class Role : MultiTenantEntityBase +{ + /// + /// 角色名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 角色编码(租户内唯一)。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 描述。 + /// + public string? Description { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RolePermission.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RolePermission.cs new file mode 100644 index 0000000..55ac3e0 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RolePermission.cs @@ -0,0 +1,19 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 角色-权限关系。 +/// +public sealed class RolePermission : MultiTenantEntityBase +{ + /// + /// 角色 ID。 + /// + public long RoleId { get; set; } + + /// + /// 权限 ID。 + /// + public long PermissionId { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/UserRole.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/UserRole.cs new file mode 100644 index 0000000..eeb147c --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/UserRole.cs @@ -0,0 +1,19 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 用户-角色关系。 +/// +public sealed class UserRole : MultiTenantEntityBase +{ + /// + /// 用户 ID。 + /// + public long UserId { get; set; } + + /// + /// 角色 ID。 + /// + public long RoleId { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs new file mode 100644 index 0000000..5b809cf --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Domain.Identity.Repositories; + +/// +/// 后台用户仓储契约。 +/// +public interface IIdentityUserRepository +{ + /// + /// 根据账号获取后台用户。 + /// + Task FindByAccountAsync(string account, CancellationToken cancellationToken = default); + + /// + /// 根据 ID 获取后台用户。 + /// + Task FindByIdAsync(long userId, CancellationToken cancellationToken = default); + + /// + /// 按租户与关键字查询后台用户列表(仅读)。 + /// + /// 租户 ID。 + /// 可选关键字(账号/名称)。 + /// 取消标记。 + Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); + + /// + /// 获取指定租户、用户集合对应的用户(只读)。 + /// + Task> GetByIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs new file mode 100644 index 0000000..41594c2 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs @@ -0,0 +1,37 @@ +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Domain.Identity.Repositories; + +/// +/// 小程序用户仓储契约。 +/// +public interface IMiniUserRepository +{ + /// + /// 根据微信 OpenId 查找小程序用户。 + /// + /// 微信 OpenId + /// 取消令牌 + /// 小程序用户,如果不存在则返回 null + Task FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default); + + /// + /// 根据用户 ID 查找小程序用户。 + /// + /// 用户 ID + /// 取消令牌 + /// 小程序用户,如果不存在则返回 null + Task FindByIdAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 创建或更新小程序用户(如果 OpenId 已存在则更新,否则创建)。 + /// + /// 微信 OpenId + /// 微信 UnionId(可选) + /// 昵称 + /// 头像地址(可选) + /// 租户 ID + /// 取消令牌 + /// 创建或更新后的小程序用户 + Task CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, long tenantId, CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs new file mode 100644 index 0000000..2bf97a0 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Domain.Identity.Repositories; + +/// +/// 权限仓储。 +/// +public interface IPermissionRepository +{ + Task FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default); + Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default); + Task> GetByIdsAsync(long tenantId, IEnumerable permissionIds, CancellationToken cancellationToken = default); + Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); + Task AddAsync(Permission permission, CancellationToken cancellationToken = default); + Task UpdateAsync(Permission permission, CancellationToken cancellationToken = default); + Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs new file mode 100644 index 0000000..6ace0ce --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Domain.Identity.Repositories; + +/// +/// 角色-权限关系仓储。 +/// +public interface IRolePermissionRepository +{ + Task> GetByRoleIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default); + Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable permissionIds, CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs new file mode 100644 index 0000000..822266e --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Domain.Identity.Repositories; + +/// +/// 角色仓储。 +/// +public interface IRoleRepository +{ + Task FindByIdAsync(long roleId, long tenantId, CancellationToken cancellationToken = default); + Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default); + Task> GetByIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default); + Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); + Task AddAsync(Role role, CancellationToken cancellationToken = default); + Task UpdateAsync(Role role, CancellationToken cancellationToken = default); + Task DeleteAsync(long roleId, long tenantId, CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs new file mode 100644 index 0000000..aa9b9c8 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Domain.Identity.Repositories; + +/// +/// 用户-角色关系仓储。 +/// +public interface IUserRoleRepository +{ + Task> GetByUserIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default); + Task> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default); + Task ReplaceUserRolesAsync(long tenantId, long userId, IEnumerable roleIds, CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryAdjustment.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryAdjustment.cs new file mode 100644 index 0000000..ef145fd --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryAdjustment.cs @@ -0,0 +1,40 @@ +using TakeoutSaaS.Domain.Inventory.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Inventory.Entities; + +/// +/// 库存调整记录。 +/// +public sealed class InventoryAdjustment : MultiTenantEntityBase +{ + /// + /// 对应的库存记录标识。 + /// + public long InventoryItemId { get; set; } + + /// + /// 调整类型。 + /// + public InventoryAdjustmentType AdjustmentType { get; set; } = InventoryAdjustmentType.Manual; + + /// + /// 调整数量,正数增加,负数减少。 + /// + public int Quantity { get; set; } + + /// + /// 原因说明。 + /// + public string? Reason { get; set; } + + /// + /// 操作人标识。 + /// + public long? OperatorId { get; set; } + + /// + /// 发生时间。 + /// + public DateTime OccurredAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs new file mode 100644 index 0000000..eee47f4 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs @@ -0,0 +1,44 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Inventory.Entities; + +/// +/// SKU 批次信息。 +/// +public sealed class InventoryBatch : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// SKU 标识。 + /// + public long ProductSkuId { get; set; } + + /// + /// 批次编号。 + /// + public string BatchNumber { get; set; } = string.Empty; + + /// + /// 生产日期。 + /// + public DateTime? ProductionDate { get; set; } + + /// + /// 过期日期。 + /// + public DateTime? ExpireDate { get; set; } + + /// + /// 入库数量。 + /// + public int Quantity { get; set; } + + /// + /// 剩余数量。 + /// + public int RemainingQuantity { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs new file mode 100644 index 0000000..6aca234 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs @@ -0,0 +1,49 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Inventory.Entities; + +/// +/// SKU 在门店的库存信息。 +/// +public sealed class InventoryItem : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// SKU 标识。 + /// + public long ProductSkuId { get; set; } + + /// + /// 批次编号,可为空表示混批。 + /// + public string? BatchNumber { get; set; } + + /// + /// 可用库存。 + /// + public int QuantityOnHand { get; set; } + + /// + /// 已锁定库存(订单占用)。 + /// + public int QuantityReserved { get; set; } + + /// + /// 安全库存阈值。 + /// + public int? SafetyStock { get; set; } + + /// + /// 储位或仓位信息。 + /// + public string? Location { get; set; } + + /// + /// 过期日期。 + /// + public DateTime? ExpireDate { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryAdjustmentType.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryAdjustmentType.cs new file mode 100644 index 0000000..58e0913 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryAdjustmentType.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Inventory.Enums; + +/// +/// 库存调整类型。 +/// +public enum InventoryAdjustmentType +{ + /// + /// 手动盘点调整。 + /// + Manual = 0, + + /// + /// 采购入库。 + /// + Purchase = 1, + + /// + /// 退货回库。 + /// + Return = 2, + + /// + /// 报损。 + /// + Damage = 3, + + /// + /// 过期销毁。 + /// + Expiration = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberGrowthLog.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberGrowthLog.cs new file mode 100644 index 0000000..b65003d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberGrowthLog.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Membership.Entities; + +/// +/// 成长值变动日志。 +/// +public sealed class MemberGrowthLog : MultiTenantEntityBase +{ + /// + /// 会员标识。 + /// + public long MemberId { get; set; } + + /// + /// 变动数量。 + /// + public int ChangeValue { get; set; } + + /// + /// 当前成长值。 + /// + public int CurrentValue { get; set; } + + /// + /// 备注。 + /// + public string? Notes { get; set; } + + /// + /// 发生时间。 + /// + public DateTime OccurredAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberPointLedger.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberPointLedger.cs new file mode 100644 index 0000000..3e4dd07 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberPointLedger.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Membership.Entities; + +/// +/// 积分变动流水。 +/// +public sealed class MemberPointLedger : MultiTenantEntityBase +{ + /// + /// 会员标识。 + /// + public long MemberId { get; set; } + + /// + /// 变动数量,可为负值。 + /// + public int ChangeAmount { get; set; } + + /// + /// 变动后余额。 + /// + public int BalanceAfterChange { get; set; } + + /// + /// 变动原因。 + /// + public PointChangeReason Reason { get; set; } = PointChangeReason.Purchase; + + /// + /// 来源 ID(订单、活动等)。 + /// + public long? SourceId { get; set; } + + /// + /// 发生时间。 + /// + public DateTime OccurredAt { get; set; } = DateTime.UtcNow; + + /// + /// 过期时间(如适用)。 + /// + public DateTime? ExpireAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberProfile.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberProfile.cs new file mode 100644 index 0000000..32df115 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberProfile.cs @@ -0,0 +1,60 @@ +using TakeoutSaaS.Domain.Membership.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Membership.Entities; + +/// +/// 会员档案。 +/// +public sealed class MemberProfile : MultiTenantEntityBase +{ + /// + /// 用户标识。 + /// + public long UserId { get; set; } + + /// + /// 手机号。 + /// + public string Mobile { get; set; } = string.Empty; + + /// + /// 昵称。 + /// + public string? Nickname { get; set; } + + /// + /// 头像。 + /// + public string? AvatarUrl { get; set; } + + /// + /// 当前会员等级 ID。 + /// + public long? MemberTierId { get; set; } + + /// + /// 会员状态。 + /// + public MemberStatus Status { get; set; } = MemberStatus.Active; + + /// + /// 会员积分余额。 + /// + public int PointsBalance { get; set; } + + /// + /// 成长值/经验值。 + /// + public int GrowthValue { get; set; } + + /// + /// 生日。 + /// + public DateTime? BirthDate { get; set; } + + /// + /// 注册时间。 + /// + public DateTime JoinedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberTier.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberTier.cs new file mode 100644 index 0000000..bd40724 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Entities/MemberTier.cs @@ -0,0 +1,29 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Membership.Entities; + +/// +/// 会员等级定义。 +/// +public sealed class MemberTier : MultiTenantEntityBase +{ + /// + /// 等级名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 所需成长值。 + /// + public int RequiredGrowth { get; set; } + + /// + /// 等级权益(JSON)。 + /// + public string BenefitsJson { get; set; } = string.Empty; + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberStatus.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberStatus.cs new file mode 100644 index 0000000..5383e3a --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/MemberStatus.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 会员状态。 +/// +public enum MemberStatus +{ + /// + /// 正常。 + /// + Active = 0, + + /// + /// 已冻结。 + /// + Frozen = 1, + + /// + /// 已注销。 + /// + Cancelled = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Membership/Enums/PointChangeReason.cs b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/PointChangeReason.cs new file mode 100644 index 0000000..61579b2 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Membership/Enums/PointChangeReason.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Membership.Enums; + +/// +/// 积分变动原因。 +/// +public enum PointChangeReason +{ + /// + /// 正常消费获得。 + /// + Purchase = 0, + + /// + /// 活动奖励。 + /// + Promotion = 1, + + /// + /// 签到或任务。 + /// + Task = 2, + + /// + /// 管理员调整。 + /// + Manual = 3, + + /// + /// 抵扣消费。 + /// + Redeem = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/Merchant.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/Merchant.cs new file mode 100644 index 0000000..d05ae41 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/Merchant.cs @@ -0,0 +1,120 @@ +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Merchants.Entities; + +/// +/// 商户主体信息,承载入驻和资质审核结果。 +/// +public sealed class Merchant : MultiTenantEntityBase +{ + /// + /// 品牌名称(对外展示)。 + /// + public string BrandName { get; set; } = string.Empty; + + /// + /// 品牌简称或别名。 + /// + public string? BrandAlias { get; set; } + + /// + /// 品牌 Logo。 + /// + public string? LogoUrl { get; set; } + + /// + /// 品牌所属品类,如火锅、咖啡等。 + /// + public string? Category { get; set; } + + /// + /// 营业执照号。 + /// + public string? BusinessLicenseNumber { get; set; } + + /// + /// 营业执照扫描件地址。 + /// + public string? BusinessLicenseImageUrl { get; set; } + + /// + /// 税号/统一社会信用代码。 + /// + public string? TaxNumber { get; set; } + + /// + /// 法人或负责人姓名。 + /// + public string? LegalPerson { get; set; } + + /// + /// 联系电话。 + /// + public string ContactPhone { get; set; } = string.Empty; + + /// + /// 联系邮箱。 + /// + public string? ContactEmail { get; set; } + + /// + /// 客服电话。 + /// + public string? ServicePhone { get; set; } + + /// + /// 客服邮箱。 + /// + public string? SupportEmail { get; set; } + + /// + /// 所在省份。 + /// + public string? Province { get; set; } + + /// + /// 所在城市。 + /// + public string? City { get; set; } + + /// + /// 所在区县。 + /// + public string? District { get; set; } + + /// + /// 详细地址。 + /// + public string? Address { get; set; } + + /// + /// 经度信息。 + /// + public double? Longitude { get; set; } + + /// + /// 纬度信息。 + /// + public double? Latitude { get; set; } + + /// + /// 入驻状态。 + /// + public MerchantStatus Status { get; set; } = MerchantStatus.Pending; + + /// + /// 审核备注或驳回原因。 + /// + public string? ReviewRemarks { get; set; } + + /// + /// 入驻时间。 + /// + public DateTime? JoinedAt { get; set; } + + /// + /// 最近一次审核时间。 + /// + public DateTime? LastReviewedAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantContract.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantContract.cs new file mode 100644 index 0000000..cf39793 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantContract.cs @@ -0,0 +1,55 @@ +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Merchants.Entities; + +/// +/// 商户合同记录。 +/// +public sealed class MerchantContract : MultiTenantEntityBase +{ + /// + /// 所属商户标识。 + /// + public long MerchantId { get; set; } + + /// + /// 合同编号。 + /// + public string ContractNumber { get; set; } = string.Empty; + + /// + /// 合同状态。 + /// + public ContractStatus Status { get; set; } = ContractStatus.Draft; + + /// + /// 合同开始时间。 + /// + public DateTime StartDate { get; set; } + + /// + /// 合同结束时间。 + /// + public DateTime EndDate { get; set; } + + /// + /// 合同文件存储地址。 + /// + public string FileUrl { get; set; } = string.Empty; + + /// + /// 签署时间。 + /// + public DateTime? SignedAt { get; set; } + + /// + /// 终止时间。 + /// + public DateTime? TerminatedAt { get; set; } + + /// + /// 终止原因。 + /// + public string? TerminationReason { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantDocument.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantDocument.cs new file mode 100644 index 0000000..433e2df --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantDocument.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Merchants.Entities; + +/// +/// 商户提交的资质或证照材料。 +/// +public sealed class MerchantDocument : MultiTenantEntityBase +{ + /// + /// 所属商户标识。 + /// + public long MerchantId { get; set; } + + /// + /// 证照类型。 + /// + public MerchantDocumentType DocumentType { get; set; } = MerchantDocumentType.BusinessLicense; + + /// + /// 审核状态。 + /// + public MerchantDocumentStatus Status { get; set; } = MerchantDocumentStatus.Pending; + + /// + /// 证照文件链接。 + /// + public string FileUrl { get; set; } = string.Empty; + + /// + /// 证照编号。 + /// + public string? DocumentNumber { get; set; } + + /// + /// 签发日期。 + /// + public DateTime? IssuedAt { get; set; } + + /// + /// 到期日期。 + /// + public DateTime? ExpiresAt { get; set; } + + /// + /// 审核备注或驳回原因。 + /// + public string? Remarks { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantStaff.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantStaff.cs new file mode 100644 index 0000000..50026e5 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantStaff.cs @@ -0,0 +1,55 @@ +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Merchants.Entities; + +/// +/// 商户员工账号,支持门店维度分配。 +/// +public sealed class MerchantStaff : MultiTenantEntityBase +{ + /// + /// 所属商户标识。 + /// + public long MerchantId { get; set; } + + /// + /// 可选的关联门店 ID。 + /// + public long? StoreId { get; set; } + + /// + /// 员工姓名。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 手机号。 + /// + public string Phone { get; set; } = string.Empty; + + /// + /// 邮箱地址。 + /// + public string? Email { get; set; } + + /// + /// 登录账号 ID(指向统一身份体系)。 + /// + public long? IdentityUserId { get; set; } + + /// + /// 员工角色类型。 + /// + public StaffRoleType RoleType { get; set; } = StaffRoleType.FrontDesk; + + /// + /// 员工状态。 + /// + public StaffStatus Status { get; set; } = StaffStatus.Active; + + /// + /// 自定义权限(JSON)。 + /// + public string? PermissionsJson { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/ContractStatus.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/ContractStatus.cs new file mode 100644 index 0000000..c38ebff --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/ContractStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Merchants.Enums; + +/// +/// 商户合同状态。 +/// +public enum ContractStatus +{ + /// + /// 草拟中。 + /// + Draft = 0, + + /// + /// 已生效。 + /// + Active = 1, + + /// + /// 已到期。 + /// + Expired = 2, + + /// + /// 已解除。 + /// + Terminated = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantDocumentStatus.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantDocumentStatus.cs new file mode 100644 index 0000000..9d1bf46 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantDocumentStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Merchants.Enums; + +/// +/// 证照审核状态。 +/// +public enum MerchantDocumentStatus +{ + /// + /// 等待审核。 + /// + Pending = 0, + + /// + /// 审核通过。 + /// + Approved = 1, + + /// + /// 审核驳回。 + /// + Rejected = 2, + + /// + /// 已过期待更新。 + /// + Expired = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantDocumentType.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantDocumentType.cs new file mode 100644 index 0000000..0dffa2d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantDocumentType.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Merchants.Enums; + +/// +/// 商户证照类型。 +/// +public enum MerchantDocumentType +{ + /// + /// 营业执照。 + /// + BusinessLicense = 0, + + /// + /// 餐饮服务许可证。 + /// + CateringPermit = 1, + + /// + /// 税务登记证。 + /// + TaxCertificate = 2, + + /// + /// 其他补充资质。 + /// + Other = 99 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantStatus.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantStatus.cs new file mode 100644 index 0000000..820e8cd --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Merchants.Enums; + +/// +/// 商户入驻状态。 +/// +public enum MerchantStatus +{ + /// + /// 等待审核。 + /// + Pending = 0, + + /// + /// 审核通过,可运营。 + /// + Approved = 1, + + /// + /// 审核未通过。 + /// + Rejected = 2, + + /// + /// 因违规或欠费被冻结。 + /// + Frozen = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/StaffRoleType.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/StaffRoleType.cs new file mode 100644 index 0000000..c5b3d33 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/StaffRoleType.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Merchants.Enums; + +/// +/// 商户员工角色。 +/// +public enum StaffRoleType +{ + /// + /// 管理员。 + /// + Admin = 0, + + /// + /// 前台收银。 + /// + FrontDesk = 1, + + /// + /// 后厨制作。 + /// + Kitchen = 2, + + /// + /// 配送骑手。 + /// + Courier = 3, + + /// + /// 运营人员。 + /// + Operator = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/StaffStatus.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/StaffStatus.cs new file mode 100644 index 0000000..20ab741 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/StaffStatus.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Merchants.Enums; + +/// +/// 员工账号状态。 +/// +public enum StaffStatus +{ + /// + /// 正常在职。 + /// + Active = 0, + + /// + /// 停用。 + /// + Disabled = 1, + + /// + /// 已离职。 + /// + Resigned = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs new file mode 100644 index 0000000..9e8710a --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Domain.Merchants.Repositories; + +/// +/// 商户聚合仓储契约,提供基础 CRUD 与查询能力。 +/// +public interface IMerchantRepository +{ + /// + /// 依据标识获取商户。 + /// + Task FindByIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 按状态筛选商户列表。 + /// + Task> SearchAsync(long tenantId, MerchantStatus? status, CancellationToken cancellationToken = default); + + /// + /// 获取指定商户的员工列表。 + /// + Task> GetStaffAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取指定商户的合同列表。 + /// + Task> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取指定商户的资质文件列表。 + /// + Task> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增商户主体。 + /// + Task AddMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default); + + /// + /// 新增商户员工。 + /// + Task AddStaffAsync(MerchantStaff staff, CancellationToken cancellationToken = default); + + /// + /// 新增商户合同。 + /// + Task AddContractAsync(MerchantContract contract, CancellationToken cancellationToken = default); + + /// + /// 新增商户资质文件。 + /// + Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default); + + /// + /// 持久化变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// 更新商户信息。 + /// + Task UpdateMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default); + + /// + /// 删除商户。 + /// + Task DeleteMerchantAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Navigation/Entities/MapLocation.cs b/src/Domain/TakeoutSaaS.Domain/Navigation/Entities/MapLocation.cs new file mode 100644 index 0000000..54524f9 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Navigation/Entities/MapLocation.cs @@ -0,0 +1,39 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Navigation.Entities; + +/// +/// 地图 POI 信息,用于门店定位和推荐。 +/// +public sealed class MapLocation : MultiTenantEntityBase +{ + /// + /// 关联门店 ID,可空表示独立 POI。 + /// + public long? StoreId { get; set; } + + /// + /// 名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 地址。 + /// + public string Address { get; set; } = string.Empty; + + /// + /// 经度。 + /// + public double Longitude { get; set; } + + /// + /// 纬度。 + /// + public double Latitude { get; set; } + + /// + /// 打车/导航落点描述。 + /// + public string? Landmark { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Navigation/Entities/NavigationRequest.cs b/src/Domain/TakeoutSaaS.Domain/Navigation/Entities/NavigationRequest.cs new file mode 100644 index 0000000..f1bdf4b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Navigation/Entities/NavigationRequest.cs @@ -0,0 +1,35 @@ +using TakeoutSaaS.Domain.Navigation.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Navigation.Entities; + +/// +/// 用户发起的导航请求日志。 +/// +public sealed class NavigationRequest : MultiTenantEntityBase +{ + /// + /// 用户 ID。 + /// + public long UserId { get; set; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; set; } + + /// + /// 来源通道(小程序、H5 等)。 + /// + public NavigationChannel Channel { get; set; } = NavigationChannel.MiniProgram; + + /// + /// 跳转的地图应用。 + /// + public NavigationTargetApp TargetApp { get; set; } = NavigationTargetApp.WechatMap; + + /// + /// 请求时间。 + /// + public DateTime RequestedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Navigation/Enums/NavigationChannel.cs b/src/Domain/TakeoutSaaS.Domain/Navigation/Enums/NavigationChannel.cs new file mode 100644 index 0000000..ed60c26 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Navigation/Enums/NavigationChannel.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Navigation.Enums; + +/// +/// 导航请求来源渠道。 +/// +public enum NavigationChannel +{ + /// + /// 小程序。 + /// + MiniProgram = 0, + + /// + /// H5/公众号。 + /// + Web = 1, + + /// + /// App。 + /// + MobileApp = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Navigation/Enums/NavigationTargetApp.cs b/src/Domain/TakeoutSaaS.Domain/Navigation/Enums/NavigationTargetApp.cs new file mode 100644 index 0000000..7e9d834 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Navigation/Enums/NavigationTargetApp.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Navigation.Enums; + +/// +/// 导航目标应用。 +/// +public enum NavigationTargetApp +{ + /// + /// 微信地图。 + /// + WechatMap = 0, + + /// + /// 腾讯地图。 + /// + Tencent = 1, + + /// + /// 高德。 + /// + Amap = 2, + + /// + /// 百度地图。 + /// + Baidu = 3, + + /// + /// Apple Map。 + /// + Apple = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Ordering/Entities/CartItem.cs b/src/Domain/TakeoutSaaS.Domain/Ordering/Entities/CartItem.cs new file mode 100644 index 0000000..655e308 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Ordering/Entities/CartItem.cs @@ -0,0 +1,55 @@ +using TakeoutSaaS.Domain.Ordering.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Ordering.Entities; + +/// +/// 购物车条目。 +/// +public sealed class CartItem : MultiTenantEntityBase +{ + /// + /// 所属购物车标识。 + /// + public long ShoppingCartId { get; set; } + + /// + /// 商品或 SKU 标识。 + /// + public long ProductId { get; set; } + + /// + /// SKU 标识。 + /// + public long? ProductSkuId { get; set; } + + /// + /// 商品名称快照。 + /// + public string ProductName { get; set; } = string.Empty; + + /// + /// 单价快照。 + /// + public decimal UnitPrice { get; set; } + + /// + /// 数量。 + /// + public int Quantity { get; set; } + + /// + /// 自定义备注(口味要求)。 + /// + public string? Remark { get; set; } + + /// + /// 状态。 + /// + public CartItemStatus Status { get; set; } = CartItemStatus.Normal; + + /// + /// 扩展 JSON(规格、加料选项等)。 + /// + public string? AttributesJson { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Ordering/Entities/CartItemAddon.cs b/src/Domain/TakeoutSaaS.Domain/Ordering/Entities/CartItemAddon.cs new file mode 100644 index 0000000..867c098 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Ordering/Entities/CartItemAddon.cs @@ -0,0 +1,29 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Ordering.Entities; + +/// +/// 购物车条目的加料/附加项。 +/// +public sealed class CartItemAddon : MultiTenantEntityBase +{ + /// + /// 所属购物车条目。 + /// + public long CartItemId { get; set; } + + /// + /// 选项名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 附加价格。 + /// + public decimal ExtraPrice { get; set; } + + /// + /// 选项 ID(可对应 ProductAddonOption)。 + /// + public long? OptionId { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Ordering/Entities/CheckoutSession.cs b/src/Domain/TakeoutSaaS.Domain/Ordering/Entities/CheckoutSession.cs new file mode 100644 index 0000000..314aaa2 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Ordering/Entities/CheckoutSession.cs @@ -0,0 +1,40 @@ +using TakeoutSaaS.Domain.Ordering.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Ordering.Entities; + +/// +/// 结账会话,记录校验上下文。 +/// +public sealed class CheckoutSession : MultiTenantEntityBase +{ + /// + /// 用户标识。 + /// + public long UserId { get; set; } + + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 会话 Token。 + /// + public string SessionToken { get; set; } = string.Empty; + + /// + /// 会话状态。 + /// + public CheckoutSessionStatus Status { get; set; } = CheckoutSessionStatus.Pending; + + /// + /// 校验结果明细 JSON。 + /// + public string ValidationResultJson { get; set; } = string.Empty; + + /// + /// 过期时间(UTC)。 + /// + public DateTime ExpiresAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Ordering/Entities/ShoppingCart.cs b/src/Domain/TakeoutSaaS.Domain/Ordering/Entities/ShoppingCart.cs new file mode 100644 index 0000000..6c7e6ff --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Ordering/Entities/ShoppingCart.cs @@ -0,0 +1,40 @@ +using TakeoutSaaS.Domain.Ordering.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Ordering.Entities; + +/// +/// 用户购物车,按租户/门店隔离。 +/// +public sealed class ShoppingCart : MultiTenantEntityBase +{ + /// + /// 用户标识。 + /// + public long UserId { get; set; } + + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 购物车状态,包含正常/锁定。 + /// + public ShoppingCartStatus Status { get; set; } = ShoppingCartStatus.Active; + + /// + /// 桌码或场景标识(扫码点餐)。 + /// + public string? TableContext { get; set; } + + /// + /// 履约方式(堂食/自提/配送)缓存。 + /// + public string? DeliveryPreference { get; set; } + + /// + /// 最近一次修改时间(UTC)。 + /// + public DateTime LastModifiedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Ordering/Enums/CartItemStatus.cs b/src/Domain/TakeoutSaaS.Domain/Ordering/Enums/CartItemStatus.cs new file mode 100644 index 0000000..f717b97 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Ordering/Enums/CartItemStatus.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Ordering.Enums; + +/// +/// 购物车条目状态。 +/// +public enum CartItemStatus +{ + /// + /// 正常可结算。 + /// + Normal = 0, + + /// + /// 不可售(售罄或下架)。 + /// + Unavailable = 1, + + /// + /// 已被删除。 + /// + Removed = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Ordering/Enums/CheckoutSessionStatus.cs b/src/Domain/TakeoutSaaS.Domain/Ordering/Enums/CheckoutSessionStatus.cs new file mode 100644 index 0000000..fbb71c8 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Ordering/Enums/CheckoutSessionStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Ordering.Enums; + +/// +/// 结账会话状态。 +/// +public enum CheckoutSessionStatus +{ + /// + /// 等待用户提交或支付。 + /// + Pending = 0, + + /// + /// 校验失败。 + /// + Failed = 1, + + /// + /// 已用于创建订单。 + /// + Completed = 2, + + /// + /// 超时作废。 + /// + Expired = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Ordering/Enums/ShoppingCartStatus.cs b/src/Domain/TakeoutSaaS.Domain/Ordering/Enums/ShoppingCartStatus.cs new file mode 100644 index 0000000..88d16af --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Ordering/Enums/ShoppingCartStatus.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Ordering.Enums; + +/// +/// 购物车状态。 +/// +public enum ShoppingCartStatus +{ + /// + /// 可正常使用。 + /// + Active = 0, + + /// + /// 已锁定(进行结账中)。 + /// + Locked = 1, + + /// + /// 已清空或失效。 + /// + Cleared = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Entities/Order.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/Order.cs new file mode 100644 index 0000000..398f8f6 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/Order.cs @@ -0,0 +1,111 @@ +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Orders.Entities; + +/// +/// 交易订单。 +/// +public sealed class Order : MultiTenantEntityBase +{ + /// + /// 订单号。 + /// + public string OrderNo { get; set; } = string.Empty; + + /// + /// 门店。 + /// + public long StoreId { get; set; } + + /// + /// 下单渠道。 + /// + public OrderChannel Channel { get; set; } = OrderChannel.MiniProgram; + + /// + /// 履约类型。 + /// + public DeliveryType DeliveryType { get; set; } = DeliveryType.DineIn; + + /// + /// 当前状态。 + /// + public OrderStatus Status { get; set; } = OrderStatus.PendingPayment; + + /// + /// 支付状态。 + /// + public PaymentStatus PaymentStatus { get; set; } = PaymentStatus.Unpaid; + + /// + /// 顾客姓名。 + /// + public string? CustomerName { get; set; } + + /// + /// 顾客手机号。 + /// + public string? CustomerPhone { get; set; } + + /// + /// 就餐桌号。 + /// + public string? TableNo { get; set; } + + /// + /// 排队号(如有)。 + /// + public string? QueueNumber { get; set; } + + /// + /// 预约 ID。 + /// + public long? ReservationId { get; set; } + + /// + /// 商品总额。 + /// + public decimal ItemsAmount { get; set; } + + /// + /// 优惠金额。 + /// + public decimal DiscountAmount { get; set; } + + /// + /// 应付金额。 + /// + public decimal PayableAmount { get; set; } + + /// + /// 实付金额。 + /// + public decimal PaidAmount { get; set; } + + /// + /// 支付时间。 + /// + public DateTime? PaidAt { get; set; } + + /// + /// 完成时间。 + /// + public DateTime? FinishedAt { get; set; } + + /// + /// 取消时间。 + /// + public DateTime? CancelledAt { get; set; } + + /// + /// 取消原因。 + /// + public string? CancelReason { get; set; } + + /// + /// 备注。 + /// + public string? Remark { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderItem.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderItem.cs new file mode 100644 index 0000000..63c5a67 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderItem.cs @@ -0,0 +1,59 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Orders.Entities; + +/// +/// 订单明细。 +/// +public sealed class OrderItem : MultiTenantEntityBase +{ + /// + /// 订单 ID。 + /// + public long OrderId { get; set; } + + /// + /// 商品 ID。 + /// + public long ProductId { get; set; } + + /// + /// 商品名称。 + /// + public string ProductName { get; set; } = string.Empty; + + /// + /// SKU/规格描述。 + /// + public string? SkuName { get; set; } + + /// + /// 单位。 + /// + public string? Unit { get; set; } + + /// + /// 数量。 + /// + public int Quantity { get; set; } + + /// + /// 单价。 + /// + public decimal UnitPrice { get; set; } + + /// + /// 折扣金额。 + /// + public decimal DiscountAmount { get; set; } + + /// + /// 小计。 + /// + public decimal SubTotal { get; set; } + + /// + /// 自定义属性 JSON。 + /// + public string? AttributesJson { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderStatusHistory.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderStatusHistory.cs new file mode 100644 index 0000000..c9b7a55 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/OrderStatusHistory.cs @@ -0,0 +1,35 @@ +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Orders.Entities; + +/// +/// 订单状态流转记录。 +/// +public sealed class OrderStatusHistory : MultiTenantEntityBase +{ + /// + /// 订单标识。 + /// + public long OrderId { get; set; } + + /// + /// 变更后的状态。 + /// + public OrderStatus Status { get; set; } + + /// + /// 操作人标识(可为空表示系统)。 + /// + public long? OperatorId { get; set; } + + /// + /// 备注信息。 + /// + public string? Notes { get; set; } + + /// + /// 发生时间。 + /// + public DateTime OccurredAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Entities/RefundRequest.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/RefundRequest.cs new file mode 100644 index 0000000..6b333e9 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Entities/RefundRequest.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Orders.Entities; + +/// +/// 售后/退款申请。 +/// +public sealed class RefundRequest : MultiTenantEntityBase +{ + /// + /// 关联订单标识。 + /// + public long OrderId { get; set; } + + /// + /// 退款单号。 + /// + public string RefundNo { get; set; } = string.Empty; + + /// + /// 申请金额。 + /// + public decimal Amount { get; set; } + + /// + /// 申请原因。 + /// + public string Reason { get; set; } = string.Empty; + + /// + /// 退款状态。 + /// + public RefundStatus Status { get; set; } = RefundStatus.Pending; + + /// + /// 用户提交时间。 + /// + public DateTime RequestedAt { get; set; } = DateTime.UtcNow; + + /// + /// 审核完成时间。 + /// + public DateTime? ProcessedAt { get; set; } + + /// + /// 审核备注。 + /// + public string? ReviewNotes { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Enums/DeliveryType.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/DeliveryType.cs new file mode 100644 index 0000000..799365b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/DeliveryType.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Orders.Enums; + +/// +/// 履约/交付方式。 +/// +public enum DeliveryType +{ + /// + /// 堂食。 + /// + DineIn = 0, + + /// + /// 门店自提。 + /// + Pickup = 1, + + /// + /// 同城配送。 + /// + Delivery = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderChannel.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderChannel.cs new file mode 100644 index 0000000..e7a1306 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderChannel.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Domain.Orders.Enums; + +/// +/// 下单渠道。 +/// +public enum OrderChannel +{ + /// + /// 未知渠道。 + /// + Unknown = 0, + + /// + /// 小程序下单。 + /// + MiniProgram = 1, + + /// + /// 扫码点餐。 + /// + ScanToOrder = 2, + + /// + /// 员工操作台。 + /// + StaffConsole = 3, + + /// + /// 电话预约。 + /// + PhoneReservation = 4, + + /// + /// 第三方配送渠道。 + /// + ThirdPartyDelivery = 5 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderStatus.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderStatus.cs new file mode 100644 index 0000000..347d61f --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/OrderStatus.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Domain.Orders.Enums; + +/// +/// 订单状态。 +/// +public enum OrderStatus +{ + /// + /// 待付款。 + /// + PendingPayment = 0, + + /// + /// 已付款待制作。 + /// + AwaitingPreparation = 1, + + /// + /// 制作/履约中。 + /// + InProgress = 2, + + /// + /// 可取餐/可自提。 + /// + Ready = 3, + + /// + /// 已完成。 + /// + Completed = 4, + + /// + /// 已取消。 + /// + Cancelled = 5 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Enums/RefundStatus.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/RefundStatus.cs new file mode 100644 index 0000000..3f2dc41 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Enums/RefundStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Orders.Enums; + +/// +/// 退款申请状态。 +/// +public enum RefundStatus +{ + /// + /// 等待审核。 + /// + Pending = 0, + + /// + /// 审核通过,待原路退款。 + /// + Approved = 1, + + /// + /// 已拒绝。 + /// + Rejected = 2, + + /// + /// 已完成退款。 + /// + Refunded = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs new file mode 100644 index 0000000..d48ecf3 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Orders.Entities; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Payments.Enums; + +namespace TakeoutSaaS.Domain.Orders.Repositories; + +/// +/// 订单聚合仓储契约。 +/// +public interface IOrderRepository +{ + /// + /// 依据标识获取订单。 + /// + Task FindByIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 依据订单号获取订单。 + /// + Task FindByOrderNoAsync(string orderNo, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 按状态筛选订单列表。 + /// + Task> SearchAsync(long tenantId, OrderStatus? status, PaymentStatus? paymentStatus, CancellationToken cancellationToken = default); + + /// + /// 获取订单明细行。 + /// + Task> GetItemsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取订单状态流转记录。 + /// + Task> GetStatusHistoryAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取订单退款申请。 + /// + Task> GetRefundsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增订单。 + /// + Task AddOrderAsync(Order order, CancellationToken cancellationToken = default); + + /// + /// 新增订单明细。 + /// + Task AddItemsAsync(IEnumerable items, CancellationToken cancellationToken = default); + + /// + /// 新增订单状态记录。 + /// + Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default); + + /// + /// 新增退款申请。 + /// + Task AddRefundAsync(RefundRequest refund, CancellationToken cancellationToken = default); + + /// + /// 持久化变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// 更新订单。 + /// + Task UpdateOrderAsync(Order order, CancellationToken cancellationToken = default); + + /// + /// 删除订单。 + /// + Task DeleteOrderAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Payments/Entities/PaymentRecord.cs b/src/Domain/TakeoutSaaS.Domain/Payments/Entities/PaymentRecord.cs new file mode 100644 index 0000000..7ba0380 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Payments/Entities/PaymentRecord.cs @@ -0,0 +1,55 @@ +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Payments.Entities; + +/// +/// 支付流水。 +/// +public sealed class PaymentRecord : MultiTenantEntityBase +{ + /// + /// 关联订单。 + /// + public long OrderId { get; set; } + + /// + /// 支付方式。 + /// + public PaymentMethod Method { get; set; } = PaymentMethod.Unknown; + + /// + /// 支付状态。 + /// + public PaymentStatus Status { get; set; } = PaymentStatus.Unpaid; + + /// + /// 支付金额。 + /// + public decimal Amount { get; set; } + + /// + /// 平台交易号。 + /// + public string? TradeNo { get; set; } + + /// + /// 第三方渠道单号。 + /// + public string? ChannelTransactionId { get; set; } + + /// + /// 支付完成时间。 + /// + public DateTime? PaidAt { get; set; } + + /// + /// 错误/备注。 + /// + public string? Remark { get; set; } + + /// + /// 原始回调内容。 + /// + public string? Payload { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Payments/Entities/PaymentRefundRecord.cs b/src/Domain/TakeoutSaaS.Domain/Payments/Entities/PaymentRefundRecord.cs new file mode 100644 index 0000000..6fa657b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Payments/Entities/PaymentRefundRecord.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Payments.Entities; + +/// +/// 支付渠道退款流水。 +/// +public sealed class PaymentRefundRecord : MultiTenantEntityBase +{ + /// + /// 原支付记录标识。 + /// + public long PaymentRecordId { get; set; } + + /// + /// 关联订单标识。 + /// + public long OrderId { get; set; } + + /// + /// 退款金额。 + /// + public decimal Amount { get; set; } + + /// + /// 渠道退款流水号。 + /// + public string? ChannelRefundId { get; set; } + + /// + /// 退款状态。 + /// + public PaymentRefundStatus Status { get; set; } = PaymentRefundStatus.Pending; + + /// + /// 退款请求时间。 + /// + public DateTime RequestedAt { get; set; } = DateTime.UtcNow; + + /// + /// 完成时间。 + /// + public DateTime? CompletedAt { get; set; } + + /// + /// 渠道返回的原始数据 JSON。 + /// + public string? Payload { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentMethod.cs b/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentMethod.cs new file mode 100644 index 0000000..7aa7807 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentMethod.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Domain.Payments.Enums; + +/// +/// 支付方式。 +/// +public enum PaymentMethod +{ + /// + /// 未知或待确定方式。 + /// + Unknown = 0, + + /// + /// 微信支付。 + /// + WeChatPay = 1, + + /// + /// 支付宝。 + /// + Alipay = 2, + + /// + /// 现金。 + /// + Cash = 3, + + /// + /// 刷卡(POS)。 + /// + Card = 4, + + /// + /// 余额或储值账户。 + /// + Balance = 5 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentRefundStatus.cs b/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentRefundStatus.cs new file mode 100644 index 0000000..0ee24db --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentRefundStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Payments.Enums; + +/// +/// 支付退款状态。 +/// +public enum PaymentRefundStatus +{ + /// + /// 已提交至渠道。 + /// + Pending = 0, + + /// + /// 退款成功。 + /// + Succeeded = 1, + + /// + /// 退款失败。 + /// + Failed = 2, + + /// + /// 渠道处理中。 + /// + Processing = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentStatus.cs b/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentStatus.cs new file mode 100644 index 0000000..550e1ee --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Payments/Enums/PaymentStatus.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Payments.Enums; + +/// +/// 支付记录状态。 +/// +public enum PaymentStatus +{ + /// + /// 未支付。 + /// + Unpaid = 0, + + /// + /// 支付处理中。 + /// + Paying = 1, + + /// + /// 支付成功。 + /// + Paid = 2, + + /// + /// 支付失败。 + /// + Failed = 3, + + /// + /// 已退款。 + /// + Refunded = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Payments/Repositories/IPaymentRepository.cs b/src/Domain/TakeoutSaaS.Domain/Payments/Repositories/IPaymentRepository.cs new file mode 100644 index 0000000..f12c937 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Payments/Repositories/IPaymentRepository.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Payments.Entities; +using TakeoutSaaS.Domain.Payments.Enums; + +namespace TakeoutSaaS.Domain.Payments.Repositories; + +/// +/// 支付记录仓储契约。 +/// +public interface IPaymentRepository +{ + /// + /// 依据标识获取支付记录。 + /// + Task FindByIdAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 依据订单标识获取支付记录。 + /// + Task FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取支付对应的退款记录。 + /// + Task> GetRefundsAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增支付记录。 + /// + Task AddPaymentAsync(PaymentRecord payment, CancellationToken cancellationToken = default); + + /// + /// 新增退款记录。 + /// + Task AddRefundAsync(PaymentRefundRecord refund, CancellationToken cancellationToken = default); + + /// + /// 持久化变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// 按状态筛选支付记录。 + /// + Task> SearchAsync(long tenantId, PaymentStatus? status, CancellationToken cancellationToken = default); + + /// + /// 更新支付记录。 + /// + Task UpdatePaymentAsync(PaymentRecord payment, CancellationToken cancellationToken = default); + + /// + /// 删除支付记录及关联退款。 + /// + Task DeletePaymentAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/Product.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/Product.cs new file mode 100644 index 0000000..953a322 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/Product.cs @@ -0,0 +1,100 @@ +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Products.Entities; + +/// +/// 商品(SPU)信息。 +/// +public sealed class Product : MultiTenantEntityBase +{ + /// + /// 所属门店。 + /// + public long StoreId { get; set; } + + /// + /// 所属分类。 + /// + public long CategoryId { get; set; } + + /// + /// 商品编码。 + /// + public string SpuCode { get; set; } = string.Empty; + + /// + /// 商品名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 副标题/卖点。 + /// + public string? Subtitle { get; set; } + + /// + /// 售卖单位(份/杯等)。 + /// + public string? Unit { get; set; } + + /// + /// 现价。 + /// + public decimal Price { get; set; } + + /// + /// 原价。 + /// + public decimal? OriginalPrice { get; set; } + + /// + /// 库存数量(可选)。 + /// + public int? StockQuantity { get; set; } + + /// + /// 最大每单限购。 + /// + public int? MaxQuantityPerOrder { get; set; } + + /// + /// 商品状态。 + /// + public ProductStatus Status { get; set; } = ProductStatus.Draft; + + /// + /// 主图。 + /// + public string? CoverImage { get; set; } + + /// + /// Gallery 图片逗号分隔。 + /// + public string? GalleryImages { get; set; } + + /// + /// 商品描述。 + /// + public string? Description { get; set; } + + /// + /// 支持堂食。 + /// + public bool EnableDineIn { get; set; } = true; + + /// + /// 支持自提。 + /// + public bool EnablePickup { get; set; } = true; + + /// + /// 支持配送。 + /// + public bool EnableDelivery { get; set; } = true; + + /// + /// 是否热门推荐。 + /// + public bool IsFeatured { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductAddonGroup.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductAddonGroup.cs new file mode 100644 index 0000000..605005a --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductAddonGroup.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Products.Entities; + +/// +/// 加料/做法分组。 +/// +public sealed class ProductAddonGroup : MultiTenantEntityBase +{ + /// + /// 所属商品。 + /// + public long ProductId { get; set; } + + /// + /// 分组名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 选择类型。 + /// + public AddonSelectionType SelectionType { get; set; } = AddonSelectionType.Single; + + /// + /// 最小选择数量。 + /// + public int? MinSelect { get; set; } + + /// + /// 最大选择数量。 + /// + public int? MaxSelect { get; set; } + + /// + /// 是否必选。 + /// + public bool IsRequired { get; set; } + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductAddonOption.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductAddonOption.cs new file mode 100644 index 0000000..3ce3047 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductAddonOption.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Products.Entities; + +/// +/// 加料选项。 +/// +public sealed class ProductAddonOption : MultiTenantEntityBase +{ + /// + /// 所属加料分组。 + /// + public long AddonGroupId { get; set; } + + /// + /// 选项名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 附加价格。 + /// + public decimal? ExtraPrice { get; set; } + + /// + /// 是否默认选项。 + /// + public bool IsDefault { get; set; } + + /// + /// 排序。 + /// + public int SortOrder { get; set; } = 100; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductAttributeGroup.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductAttributeGroup.cs new file mode 100644 index 0000000..f9b3269 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductAttributeGroup.cs @@ -0,0 +1,40 @@ +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Products.Entities; + +/// +/// 商品规格/属性分组。 +/// +public sealed class ProductAttributeGroup : MultiTenantEntityBase +{ + /// + /// 关联门店,可为空表示所有门店共享。 + /// + public long? StoreId { get; set; } + + /// + /// 所属商品标识。 + /// + public long ProductId { get; set; } + + /// + /// 分组名称,例如“辣度”“份量”。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 选择类型(单选/多选)。 + /// + public AttributeSelectionType SelectionType { get; set; } = AttributeSelectionType.Single; + + /// + /// 是否必选。 + /// + public bool IsRequired { get; set; } + + /// + /// 显示排序。 + /// + public int SortOrder { get; set; } = 100; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductAttributeOption.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductAttributeOption.cs new file mode 100644 index 0000000..1ae3c73 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductAttributeOption.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Products.Entities; + +/// +/// 商品规格选项。 +/// +public sealed class ProductAttributeOption : MultiTenantEntityBase +{ + /// + /// 所属规格组。 + /// + public long AttributeGroupId { get; set; } + + /// + /// 选项名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 附加价格。 + /// + public decimal? ExtraPrice { get; set; } + + /// + /// 排序。 + /// + public int SortOrder { get; set; } = 100; + + /// + /// 是否默认选中。 + /// + public bool IsDefault { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductCategory.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductCategory.cs new file mode 100644 index 0000000..ab2afb2 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductCategory.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Products.Entities; + +/// +/// 商品分类。 +/// +public sealed class ProductCategory : MultiTenantEntityBase +{ + /// + /// 所属门店。 + /// + public long StoreId { get; set; } + + /// + /// 分类名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 分类描述。 + /// + public string? Description { get; set; } + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductMediaAsset.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductMediaAsset.cs new file mode 100644 index 0000000..a963b26 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductMediaAsset.cs @@ -0,0 +1,35 @@ +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Products.Entities; + +/// +/// 商品媒资素材。 +/// +public sealed class ProductMediaAsset : MultiTenantEntityBase +{ + /// + /// 商品标识。 + /// + public long ProductId { get; set; } + + /// + /// 媒体类型。 + /// + public MediaAssetType MediaType { get; set; } = MediaAssetType.Image; + + /// + /// 媒资链接。 + /// + public string Url { get; set; } = string.Empty; + + /// + /// 描述或标题。 + /// + public string? Caption { get; set; } + + /// + /// 排序。 + /// + public int SortOrder { get; set; } = 100; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductPricingRule.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductPricingRule.cs new file mode 100644 index 0000000..a92c0fe --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductPricingRule.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Products.Entities; + +/// +/// 商品价格策略,支持会员价/时段价等。 +/// +public sealed class ProductPricingRule : MultiTenantEntityBase +{ + /// + /// 所属商品。 + /// + public long ProductId { get; set; } + + /// + /// 策略类型。 + /// + public PricingRuleType RuleType { get; set; } = PricingRuleType.Member; + + /// + /// 条件描述(JSON),如会员等级、渠道等。 + /// + public string ConditionsJson { get; set; } = string.Empty; + + /// + /// 特殊价格。 + /// + public decimal Price { get; set; } + + /// + /// 生效开始时间。 + /// + public DateTime? StartTime { get; set; } + + /// + /// 生效结束时间。 + /// + public DateTime? EndTime { get; set; } + + /// + /// 生效星期(JSON 数组)。 + /// + public string? WeekdaysJson { get; set; } + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSku.cs b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSku.cs new file mode 100644 index 0000000..f20fc17 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Entities/ProductSku.cs @@ -0,0 +1,54 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Products.Entities; + +/// +/// 商品 SKU,记录具体规格组合价格。 +/// +public sealed class ProductSku : MultiTenantEntityBase +{ + /// + /// 所属商品标识。 + /// + public long ProductId { get; set; } + + /// + /// SKU 编码。 + /// + public string SkuCode { get; set; } = string.Empty; + + /// + /// 条形码。 + /// + public string? Barcode { get; set; } + + /// + /// 售价。 + /// + public decimal Price { get; set; } + + /// + /// 原价。 + /// + public decimal? OriginalPrice { get; set; } + + /// + /// 可售库存。 + /// + public int? StockQuantity { get; set; } + + /// + /// 重量(千克)。 + /// + public decimal? Weight { get; set; } + + /// + /// 规格属性 JSON(记录选项 ID)。 + /// + public string AttributesJson { get; set; } = string.Empty; + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Enums/AddonSelectionType.cs b/src/Domain/TakeoutSaaS.Domain/Products/Enums/AddonSelectionType.cs new file mode 100644 index 0000000..65bea66 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Enums/AddonSelectionType.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Products.Enums; + +/// +/// 加料选择类型。 +/// +public enum AddonSelectionType +{ + /// + /// 单选。 + /// + Single = 0, + + /// + /// 多选。 + /// + Multiple = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Enums/AttributeSelectionType.cs b/src/Domain/TakeoutSaaS.Domain/Products/Enums/AttributeSelectionType.cs new file mode 100644 index 0000000..9b5f69d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Enums/AttributeSelectionType.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Products.Enums; + +/// +/// 规格/加料的选择方式。 +/// +public enum AttributeSelectionType +{ + /// + /// 单选。 + /// + Single = 0, + + /// + /// 多选。 + /// + Multiple = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Enums/MediaAssetType.cs b/src/Domain/TakeoutSaaS.Domain/Products/Enums/MediaAssetType.cs new file mode 100644 index 0000000..27a0f2d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Enums/MediaAssetType.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Products.Enums; + +/// +/// 商品媒资类型。 +/// +public enum MediaAssetType +{ + /// + /// 图片。 + /// + Image = 0, + + /// + /// 视频。 + /// + Video = 1, + + /// + /// PDF 或说明文档。 + /// + Document = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Enums/PricingRuleType.cs b/src/Domain/TakeoutSaaS.Domain/Products/Enums/PricingRuleType.cs new file mode 100644 index 0000000..613283a --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Enums/PricingRuleType.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Products.Enums; + +/// +/// 价格策略类型。 +/// +public enum PricingRuleType +{ + /// + /// 会员价格。 + /// + Member = 0, + + /// + /// 不同门店价格。 + /// + Store = 1, + + /// + /// 时间段价格。 + /// + TimePeriod = 2, + + /// + /// 区域价格。 + /// + Region = 3, + + /// + /// 活动价格。 + /// + Promotion = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Enums/ProductStatus.cs b/src/Domain/TakeoutSaaS.Domain/Products/Enums/ProductStatus.cs new file mode 100644 index 0000000..916679b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Enums/ProductStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Products.Enums; + +/// +/// 商品状态。 +/// +public enum ProductStatus +{ + /// + /// 草稿,尚未上架。 + /// + Draft = 0, + + /// + /// 已上架售卖中。 + /// + OnSale = 1, + + /// + /// 临时下架。 + /// + OffShelf = 2, + + /// + /// 归档不再使用。 + /// + Archived = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs new file mode 100644 index 0000000..6f01804 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs @@ -0,0 +1,148 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Enums; + +namespace TakeoutSaaS.Domain.Products.Repositories; + +/// +/// 商品聚合仓储契约。 +/// +public interface IProductRepository +{ + /// + /// 依据标识获取商品。 + /// + Task FindByIdAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 按分类与状态筛选商品列表。 + /// + Task> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default); + + /// + /// 获取租户下的商品分类。 + /// + Task> GetCategoriesAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取商品 SKU。 + /// + Task> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取商品加料组与选项。 + /// + Task> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取商品加料选项。 + /// + Task> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取商品规格组与选项。 + /// + Task> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取商品规格选项。 + /// + Task> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取商品媒资。 + /// + Task> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取商品定价规则。 + /// + Task> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增分类。 + /// + Task AddCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default); + + /// + /// 新增商品。 + /// + Task AddProductAsync(Product product, CancellationToken cancellationToken = default); + + /// + /// 新增 SKU。 + /// + Task AddSkusAsync(IEnumerable skus, CancellationToken cancellationToken = default); + + /// + /// 新增加料组与选项。 + /// + Task AddAddonGroupsAsync(IEnumerable groups, IEnumerable options, CancellationToken cancellationToken = default); + + /// + /// 新增规格组与选项。 + /// + Task AddAttributeGroupsAsync(IEnumerable groups, IEnumerable options, CancellationToken cancellationToken = default); + + /// + /// 新增媒资。 + /// + Task AddMediaAssetsAsync(IEnumerable assets, CancellationToken cancellationToken = default); + + /// + /// 新增定价规则。 + /// + Task AddPricingRulesAsync(IEnumerable rules, CancellationToken cancellationToken = default); + + /// + /// 持久化变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// 更新商品。 + /// + Task UpdateProductAsync(Product product, CancellationToken cancellationToken = default); + + /// + /// 删除商品。 + /// + Task DeleteProductAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 更新分类。 + /// + Task UpdateCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default); + + /// + /// 删除分类。 + /// + Task DeleteCategoryAsync(long categoryId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除商品下的 SKU。 + /// + Task RemoveSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除商品下的加料组及选项。 + /// + Task RemoveAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除商品下的规格组及选项。 + /// + Task RemoveAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除商品媒资。 + /// + Task RemoveMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除商品定价规则。 + /// + Task RemovePricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Queues/Entities/QueueTicket.cs b/src/Domain/TakeoutSaaS.Domain/Queues/Entities/QueueTicket.cs new file mode 100644 index 0000000..e235909 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Queues/Entities/QueueTicket.cs @@ -0,0 +1,52 @@ +using TakeoutSaaS.Domain.Queues.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Queues.Entities; + +/// +/// 排队叫号。 +/// +public sealed class QueueTicket : MultiTenantEntityBase +{ + public long StoreId { get; set; } + + /// + /// 排队编号。 + /// + public string TicketNumber { get; set; } = string.Empty; + + /// + /// 就餐人数。 + /// + public int PartySize { get; set; } + + /// + /// 状态。 + /// + public QueueStatus Status { get; set; } = QueueStatus.Waiting; + + /// + /// 预计等待分钟。 + /// + public int? EstimatedWaitMinutes { get; set; } + + /// + /// 叫号时间。 + /// + public DateTime? CalledAt { get; set; } + + /// + /// 过号时间。 + /// + public DateTime? ExpiredAt { get; set; } + + /// + /// 取消时间。 + /// + public DateTime? CancelledAt { get; set; } + + /// + /// 备注。 + /// + public string? Remark { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Queues/Enums/QueueStatus.cs b/src/Domain/TakeoutSaaS.Domain/Queues/Enums/QueueStatus.cs new file mode 100644 index 0000000..8149725 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Queues/Enums/QueueStatus.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Queues.Enums; + +/// +/// 排队状态。 +/// +public enum QueueStatus +{ + /// + /// 正在排队等待。 + /// + Waiting = 0, + + /// + /// 已叫号。 + /// + Calling = 1, + + /// + /// 完成入座或取餐。 + /// + Completed = 2, + + /// + /// 用户主动取消。 + /// + Cancelled = 3, + + /// + /// 过号未到。 + /// + Expired = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Reservations/Entities/Reservation.cs b/src/Domain/TakeoutSaaS.Domain/Reservations/Entities/Reservation.cs new file mode 100644 index 0000000..1e846e8 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Reservations/Entities/Reservation.cs @@ -0,0 +1,70 @@ +using TakeoutSaaS.Domain.Reservations.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Reservations.Entities; + +/// +/// 预约/预订记录。 +/// +public sealed class Reservation : MultiTenantEntityBase +{ + /// + /// 门店。 + /// + public long StoreId { get; set; } + + /// + /// 预约号。 + /// + public string ReservationNo { get; set; } = string.Empty; + + /// + /// 客户姓名。 + /// + public string CustomerName { get; set; } = string.Empty; + + /// + /// 联系电话。 + /// + public string CustomerPhone { get; set; } = string.Empty; + + /// + /// 用餐人数。 + /// + public int PeopleCount { get; set; } + + /// + /// 预约时间(UTC)。 + /// + public DateTime ReservationTime { get; set; } + + /// + /// 状态。 + /// + public ReservationStatus Status { get; set; } = ReservationStatus.Pending; + + /// + /// 桌型/标签。 + /// + public string? TablePreference { get; set; } + + /// + /// 备注。 + /// + public string? Remark { get; set; } + + /// + /// 核销码/到店码。 + /// + public string? CheckInCode { get; set; } + + /// + /// 实际签到时间。 + /// + public DateTime? CheckedInAt { get; set; } + + /// + /// 取消时间。 + /// + public DateTime? CancelledAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Reservations/Enums/ReservationStatus.cs b/src/Domain/TakeoutSaaS.Domain/Reservations/Enums/ReservationStatus.cs new file mode 100644 index 0000000..024bcfb --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Reservations/Enums/ReservationStatus.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Reservations.Enums; + +/// +/// 预约状态。 +/// +public enum ReservationStatus +{ + /// + /// 已提交待确认。 + /// + Pending = 0, + + /// + /// 预约已确认。 + /// + Confirmed = 1, + + /// + /// 客户已到店签到。 + /// + CheckedIn = 2, + + /// + /// 已取消。 + /// + Cancelled = 3, + + /// + /// 未到店被标记为爽约。 + /// + NoShow = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/Store.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/Store.cs new file mode 100644 index 0000000..f3c8e44 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/Store.cs @@ -0,0 +1,130 @@ +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店信息,承载营业配置与能力。 +/// +public sealed class Store : MultiTenantEntityBase +{ + /// + /// 所属商户标识。 + /// + public long MerchantId { get; set; } + + /// + /// 门店编码,便于扫码及外部对接。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 门店名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 联系电话。 + /// + public string? Phone { get; set; } + + /// + /// 门店负责人姓名。 + /// + public string? ManagerName { get; set; } + + /// + /// 门店当前运营状态。 + /// + public StoreStatus Status { get; set; } = StoreStatus.Closed; + + /// + /// 所在国家或地区。 + /// + public string? Country { get; set; } + + /// + /// 所在省份。 + /// + public string? Province { get; set; } + + /// + /// 所在城市。 + /// + public string? City { get; set; } + + /// + /// 区县信息。 + /// + public string? District { get; set; } + + /// + /// 详细地址。 + /// + public string? Address { get; set; } + + /// + /// 高德/腾讯地图经度。 + /// + public double? Longitude { get; set; } + + /// + /// 纬度。 + /// + public double? Latitude { get; set; } + + /// + /// 门店描述或公告。 + /// + public string? Description { get; set; } + + /// + /// 门店营业时段描述(备用字符串)。 + /// + public string? BusinessHours { get; set; } + + /// + /// 是否支持堂食。 + /// + public bool SupportsDineIn { get; set; } = true; + + /// + /// 是否支持自提。 + /// + public bool SupportsPickup { get; set; } = true; + + /// + /// 是否支持配送。 + /// + public bool SupportsDelivery { get; set; } = true; + + /// + /// 支持预约。 + /// + public bool SupportsReservation { get; set; } + + /// + /// 支持排队叫号。 + /// + public bool SupportsQueueing { get; set; } + + /// + /// 默认配送半径(公里)。 + /// + public decimal DeliveryRadiusKm { get; set; } = 3m; + + /// + /// 门店公告。 + /// + public string? Announcement { get; set; } + + /// + /// 门店标签(逗号分隔)。 + /// + public string? Tags { get; set; } + + /// + /// 门店海报。 + /// + public string? CoverImageUrl { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreBusinessHour.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreBusinessHour.cs new file mode 100644 index 0000000..083a46f --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreBusinessHour.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店营业时段配置。 +/// +public sealed class StoreBusinessHour : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 星期几,0 表示周日。 + /// + public DayOfWeek DayOfWeek { get; set; } + + /// + /// 时段类型(正常营业、休息、预约等)。 + /// + public BusinessHourType HourType { get; set; } = BusinessHourType.Normal; + + /// + /// 开始时间(本地时间)。 + /// + public TimeSpan StartTime { get; set; } + + /// + /// 结束时间(本地时间)。 + /// + public TimeSpan EndTime { get; set; } + + /// + /// 最大接待容量或单量限制。 + /// + public int? CapacityLimit { get; set; } + + /// + /// 备注。 + /// + public string? Notes { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreDeliveryZone.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreDeliveryZone.cs new file mode 100644 index 0000000..9e72e51 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreDeliveryZone.cs @@ -0,0 +1,44 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店配送范围配置。 +/// +public sealed class StoreDeliveryZone : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 区域名称。 + /// + public string ZoneName { get; set; } = string.Empty; + + /// + /// GeoJSON 表示的多边形范围。 + /// + public string PolygonGeoJson { get; set; } = string.Empty; + + /// + /// 起送价。 + /// + public decimal? MinimumOrderAmount { get; set; } + + /// + /// 配送费。 + /// + public decimal? DeliveryFee { get; set; } + + /// + /// 预计送达分钟。 + /// + public int? EstimatedMinutes { get; set; } + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreEmployeeShift.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreEmployeeShift.cs new file mode 100644 index 0000000..a1f0b39 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreEmployeeShift.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店员工排班记录。 +/// +public sealed class StoreEmployeeShift : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 员工标识。 + /// + public long StaffId { get; set; } + + /// + /// 班次日期。 + /// + public DateTime ShiftDate { get; set; } + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; set; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; set; } + + /// + /// 排班角色。 + /// + public StaffRoleType RoleType { get; set; } = StaffRoleType.FrontDesk; + + /// + /// 备注。 + /// + public string? Notes { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreHoliday.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreHoliday.cs new file mode 100644 index 0000000..e10edce --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreHoliday.cs @@ -0,0 +1,29 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店休息日或特殊营业日。 +/// +public sealed class StoreHoliday : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 日期。 + /// + public DateTime Date { get; set; } + + /// + /// 是否全天闭店。 + /// + public bool IsClosed { get; set; } = true; + + /// + /// 说明内容。 + /// + public string? Reason { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreTable.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreTable.cs new file mode 100644 index 0000000..97dc86b --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreTable.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 桌台信息与二维码绑定。 +/// +public sealed class StoreTable : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 所在区域 ID。 + /// + public long? AreaId { get; set; } + + /// + /// 桌码。 + /// + public string TableCode { get; set; } = string.Empty; + + /// + /// 可容纳人数。 + /// + public int Capacity { get; set; } + + /// + /// 桌台标签(堂食、快餐等)。 + /// + public string? Tags { get; set; } + + /// + /// 当前桌台状态。 + /// + public StoreTableStatus Status { get; set; } = StoreTableStatus.Idle; + + /// + /// 桌码二维码地址。 + /// + public string? QrCodeUrl { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreTableArea.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreTableArea.cs new file mode 100644 index 0000000..0fb6fb6 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StoreTableArea.cs @@ -0,0 +1,29 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店桌台区域配置。 +/// +public sealed class StoreTableArea : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 区域名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 区域描述。 + /// + public string? Description { get; set; } + + /// + /// 排序值。 + /// + public int SortOrder { get; set; } = 100; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Enums/BusinessHourType.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/BusinessHourType.cs new file mode 100644 index 0000000..9f545a0 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/BusinessHourType.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Stores.Enums; + +/// +/// 营业时段类型。 +/// +public enum BusinessHourType +{ + /// + /// 正常营业时段。 + /// + Normal = 0, + + /// + /// 预留给预约的时段。 + /// + ReservationOnly = 1, + + /// + /// 仅自提或配送的时段。 + /// + PickupOrDelivery = 2, + + /// + /// 休息时段。 + /// + Closed = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreStatus.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreStatus.cs new file mode 100644 index 0000000..df82fb9 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Stores.Enums; + +/// +/// 门店运营状态。 +/// +public enum StoreStatus +{ + /// + /// 未开业或休眠。 + /// + Closed = 0, + + /// + /// 准备营业。 + /// + Preparing = 1, + + /// + /// 正常营业中。 + /// + Operating = 2, + + /// + /// 暂停营业。 + /// + Suspended = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreTableStatus.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreTableStatus.cs new file mode 100644 index 0000000..073b832 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Enums/StoreTableStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Stores.Enums; + +/// +/// 桌台占用状态。 +/// +public enum StoreTableStatus +{ + /// + /// 空闲可用。 + /// + Idle = 0, + + /// + /// 已被占用。 + /// + Occupied = 1, + + /// + /// 正在清理。 + /// + Cleaning = 2, + + /// + /// 暂停使用。 + /// + Disabled = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs new file mode 100644 index 0000000..bbbf7e0 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Domain.Stores.Repositories; + +/// +/// 门店聚合仓储契约。 +/// +public interface IStoreRepository +{ + /// + /// 依据标识获取门店。 + /// + Task FindByIdAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 按租户筛选门店列表。 + /// + Task> SearchAsync(long tenantId, StoreStatus? status, CancellationToken cancellationToken = default); + + /// + /// 获取门店营业时段。 + /// + Task> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取门店配送区域配置。 + /// + Task> GetDeliveryZonesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取门店节假日配置。 + /// + Task> GetHolidaysAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取门店桌台区域。 + /// + Task> GetTableAreasAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取门店桌台列表。 + /// + Task> GetTablesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取门店员工排班。 + /// + Task> GetShiftsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增门店。 + /// + Task AddStoreAsync(Store store, CancellationToken cancellationToken = default); + + /// + /// 新增营业时段。 + /// + Task AddBusinessHoursAsync(IEnumerable hours, CancellationToken cancellationToken = default); + + /// + /// 新增配送区域。 + /// + Task AddDeliveryZonesAsync(IEnumerable zones, CancellationToken cancellationToken = default); + + /// + /// 新增节假日配置。 + /// + Task AddHolidaysAsync(IEnumerable holidays, CancellationToken cancellationToken = default); + + /// + /// 新增桌台区域。 + /// + Task AddTableAreasAsync(IEnumerable areas, CancellationToken cancellationToken = default); + + /// + /// 新增桌台。 + /// + Task AddTablesAsync(IEnumerable tables, CancellationToken cancellationToken = default); + + /// + /// 新增排班。 + /// + Task AddShiftsAsync(IEnumerable shifts, CancellationToken cancellationToken = default); + + /// + /// 持久化变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); + + /// + /// 更新门店。 + /// + Task UpdateStoreAsync(Store store, CancellationToken cancellationToken = default); + + /// + /// 删除门店。 + /// + Task DeleteStoreAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/SystemParameters/Entities/SystemParameter.cs b/src/Domain/TakeoutSaaS.Domain/SystemParameters/Entities/SystemParameter.cs new file mode 100644 index 0000000..a6c7826 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/SystemParameters/Entities/SystemParameter.cs @@ -0,0 +1,34 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.SystemParameters.Entities; + +/// +/// 系统参数实体:支持按租户维护的键值型配置。 +/// +public sealed class SystemParameter : MultiTenantEntityBase +{ + /// + /// 参数键,租户内唯一。 + /// + public string Key { get; set; } = string.Empty; + + /// + /// 参数值,支持文本或 JSON。 + /// + public string Value { get; set; } = string.Empty; + + /// + /// 描述信息。 + /// + public string? Description { get; set; } + + /// + /// 排序值,越小越靠前。 + /// + public int SortOrder { get; set; } = 100; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/SystemParameters/Repositories/ISystemParameterRepository.cs b/src/Domain/TakeoutSaaS.Domain/SystemParameters/Repositories/ISystemParameterRepository.cs new file mode 100644 index 0000000..c222342 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/SystemParameters/Repositories/ISystemParameterRepository.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.SystemParameters.Entities; + +namespace TakeoutSaaS.Domain.SystemParameters.Repositories; + +/// +/// 系统参数仓储接口:提供基础 CRUD 与查询能力。 +/// +public interface ISystemParameterRepository +{ + /// + /// 根据标识获取系统参数。 + /// + Task FindByIdAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 根据键获取系统参数(当前租户)。 + /// + Task FindByKeyAsync(string key, CancellationToken cancellationToken = default); + + /// + /// 查询系统参数列表。 + /// + Task> SearchAsync(string? keyword, bool? isEnabled, CancellationToken cancellationToken = default); + + /// + /// 新增系统参数。 + /// + Task AddAsync(SystemParameter parameter, CancellationToken cancellationToken = default); + + /// + /// 删除系统参数。 + /// + Task RemoveAsync(SystemParameter parameter, CancellationToken cancellationToken = default); + + /// + /// 更新系统参数。 + /// + Task UpdateAsync(SystemParameter parameter, CancellationToken cancellationToken = default); + + /// + /// 持久化更改。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/TakeoutSaaS.Domain.csproj b/src/Domain/TakeoutSaaS.Domain/TakeoutSaaS.Domain.csproj new file mode 100644 index 0000000..3f9021d --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/TakeoutSaaS.Domain.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + true + 1591 + + + + + + diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/Tenant.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/Tenant.cs new file mode 100644 index 0000000..b2823e3 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/Tenant.cs @@ -0,0 +1,125 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 平台租户信息,描述租户的生命周期与基础资料。 +/// +public sealed class Tenant : AuditableEntityBase +{ + /// + /// 租户短编码,作为跨系统引用的唯一标识。 + /// + public string Code { get; set; } = string.Empty; + + /// + /// 租户全称或品牌名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 对外展示的简称。 + /// + public string? ShortName { get; set; } + + /// + /// 法人或公司主体名称。 + /// + public string? LegalEntityName { get; set; } + + /// + /// 所属行业,如餐饮、零售等。 + /// + public string? Industry { get; set; } + + /// + /// LOGO 图片地址。 + /// + public string? LogoUrl { get; set; } + + /// + /// 品牌海报或封面图。 + /// + public string? CoverImageUrl { get; set; } + + /// + /// 官网或主要宣传链接。 + /// + public string? Website { get; set; } + + /// + /// 所在国家/地区。 + /// + public string? Country { get; set; } + + /// + /// 所在省份或州。 + /// + public string? Province { get; set; } + + /// + /// 所在城市。 + /// + public string? City { get; set; } + + /// + /// 详细地址信息。 + /// + public string? Address { get; set; } + + /// + /// 主联系人姓名。 + /// + public string? ContactName { get; set; } + + /// + /// 主联系人电话。 + /// + public string? ContactPhone { get; set; } + + /// + /// 主联系人邮箱。 + /// + public string? ContactEmail { get; set; } + + /// + /// 系统内对应的租户所有者账号 ID。 + /// + public long? PrimaryOwnerUserId { get; set; } + + /// + /// 租户当前状态,涵盖审核、启用、停用等场景。 + /// + public TenantStatus Status { get; set; } = TenantStatus.PendingReview; + + /// + /// 服务生效时间(UTC)。 + /// + public DateTime? EffectiveFrom { get; set; } + + /// + /// 服务到期时间(UTC)。 + /// + public DateTime? EffectiveTo { get; set; } + + /// + /// 最近一次暂停服务时间。 + /// + public DateTime? SuspendedAt { get; set; } + + /// + /// 暂停或终止的原因说明。 + /// + public string? SuspensionReason { get; set; } + + /// + /// 业务标签集合(逗号分隔)。 + /// + public string? Tags { get; set; } + + /// + /// 备注信息,用于运营记录特殊说明。 + /// + public string? Remarks { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantBillingStatement.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantBillingStatement.cs new file mode 100644 index 0000000..4fb50e2 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantBillingStatement.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户账单,用于呈现周期性收费。 +/// +public sealed class TenantBillingStatement : MultiTenantEntityBase +{ + /// + /// 账单编号,供对账查询。 + /// + public string StatementNo { get; set; } = string.Empty; + + /// + /// 账单周期开始时间。 + /// + public DateTime PeriodStart { get; set; } + + /// + /// 账单周期结束时间。 + /// + public DateTime PeriodEnd { get; set; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; set; } + + /// + /// 实付金额。 + /// + public decimal AmountPaid { get; set; } + + /// + /// 当前付款状态。 + /// + public TenantBillingStatus Status { get; set; } = TenantBillingStatus.Pending; + + /// + /// 到期日。 + /// + public DateTime DueDate { get; set; } + + /// + /// 账单明细 JSON,记录各项费用。 + /// + public string? LineItemsJson { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantNotification.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantNotification.cs new file mode 100644 index 0000000..8d2b881 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantNotification.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 面向租户的站内通知或消息推送。 +/// +public sealed class TenantNotification : MultiTenantEntityBase +{ + /// + /// 通知标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 通知正文。 + /// + public string Message { get; set; } = string.Empty; + + /// + /// 发布通道(站内、邮件、短信等)。 + /// + public TenantNotificationChannel Channel { get; set; } = TenantNotificationChannel.InApp; + + /// + /// 通知重要级别。 + /// + public TenantNotificationSeverity Severity { get; set; } = TenantNotificationSeverity.Info; + + /// + /// 推送时间。 + /// + public DateTime SentAt { get; set; } = DateTime.UtcNow; + + /// + /// 租户是否已阅读。 + /// + public DateTime? ReadAt { get; set; } + + /// + /// 附加元数据 JSON。 + /// + public string? MetadataJson { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs new file mode 100644 index 0000000..807f159 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantPackage.cs @@ -0,0 +1,70 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 平台提供的租户套餐定义。 +/// +public sealed class TenantPackage : AuditableEntityBase +{ + /// + /// 套餐名称,展示给租户的简称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 套餐描述,包含适用场景、权益等。 + /// + public string? Description { get; set; } + + /// + /// 套餐分类(试用、标准、旗舰等)。 + /// + public TenantPackageType PackageType { get; set; } = TenantPackageType.Standard; + + /// + /// 月付价格,单位:人民币元。 + /// + public decimal? MonthlyPrice { get; set; } + + /// + /// 年付价格,单位:人民币元。 + /// + public decimal? YearlyPrice { get; set; } + + /// + /// 允许的最大门店数。 + /// + public int? MaxStoreCount { get; set; } + + /// + /// 允许创建的最大账号数。 + /// + public int? MaxAccountCount { get; set; } + + /// + /// 存储容量上限(GB)。 + /// + public int? MaxStorageGb { get; set; } + + /// + /// 每月短信额度上限。 + /// + public int? MaxSmsCredits { get; set; } + + /// + /// 每月可调用的配送单数量上限。 + /// + public int? MaxDeliveryOrders { get; set; } + + /// + /// 权益明细 JSON,记录自定义特性开关。 + /// + public string? FeaturePoliciesJson { get; set; } + + /// + /// 是否仍可售卖。 + /// + public bool IsActive { get; set; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantQuotaUsage.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantQuotaUsage.cs new file mode 100644 index 0000000..5b2f4c5 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantQuotaUsage.cs @@ -0,0 +1,35 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户配额使用情况快照。 +/// +public sealed class TenantQuotaUsage : MultiTenantEntityBase +{ + /// + /// 配额类型,例如门店数、短信条数等。 + /// + public TenantQuotaType QuotaType { get; set; } + + /// + /// 当前配额上限。 + /// + public decimal LimitValue { get; set; } + + /// + /// 已消耗的数量。 + /// + public decimal UsedValue { get; set; } + + /// + /// 配额刷新周期描述(如月、年)。 + /// + public string? ResetCycle { get; set; } + + /// + /// 最近一次重置时间。 + /// + public DateTime? LastResetAt { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscription.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscription.cs new file mode 100644 index 0000000..3090be2 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscription.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户套餐订阅记录。 +/// +public sealed class TenantSubscription : MultiTenantEntityBase +{ + /// + /// 当前订阅关联的套餐标识。 + /// + public long TenantPackageId { get; set; } + + /// + /// 订阅生效时间(UTC)。 + /// + public DateTime EffectiveFrom { get; set; } + + /// + /// 订阅到期时间(UTC)。 + /// + public DateTime EffectiveTo { get; set; } + + /// + /// 下一个计费时间,配合自动续费使用。 + /// + public DateTime? NextBillingDate { get; set; } + + /// + /// 订阅当前状态。 + /// + public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Pending; + + /// + /// 是否开启自动续费。 + /// + public bool AutoRenew { get; set; } + + /// + /// 若已排期升降配,对应的新套餐 ID。 + /// + public long? ScheduledPackageId { get; set; } + + /// + /// 运营备注信息。 + /// + public string? Notes { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionStatus.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionStatus.cs new file mode 100644 index 0000000..d1d1a49 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionStatus.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 订阅状态。 +/// +public enum SubscriptionStatus +{ + /// + /// 尚未支付或等待审批。 + /// + Pending = 0, + + /// + /// 订阅已生效。 + /// + Active = 1, + + /// + /// 已到期但仍保留数据。 + /// + GracePeriod = 2, + + /// + /// 已取消。 + /// + Cancelled = 3, + + /// + /// 因欠费被暂停。 + /// + Suspended = 4 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantBillingStatus.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantBillingStatus.cs new file mode 100644 index 0000000..11671d2 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantBillingStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 账单状态。 +/// +public enum TenantBillingStatus +{ + /// + /// 等待付款。 + /// + Pending = 0, + + /// + /// 已付款结清。 + /// + Paid = 1, + + /// + /// 已逾期。 + /// + Overdue = 2, + + /// + /// 已取消或作废。 + /// + Cancelled = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantNotificationChannel.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantNotificationChannel.cs new file mode 100644 index 0000000..25a3a36 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantNotificationChannel.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 通知推送渠道。 +/// +public enum TenantNotificationChannel +{ + /// + /// 站内消息。 + /// + InApp = 0, + + /// + /// 邮件推送。 + /// + Email = 1, + + /// + /// 短信提醒。 + /// + Sms = 2, + + /// + /// 管理后台弹窗。 + /// + Portal = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantNotificationSeverity.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantNotificationSeverity.cs new file mode 100644 index 0000000..7947059 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantNotificationSeverity.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 租户通知的重要程度。 +/// +public enum TenantNotificationSeverity +{ + /// + /// 普通提示。 + /// + Info = 0, + + /// + /// 需要关注的提醒。 + /// + Warning = 1, + + /// + /// 影响业务的严重事件。 + /// + Critical = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPackageType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPackageType.cs new file mode 100644 index 0000000..111ae2c --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantPackageType.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 套餐类型枚举。 +/// +public enum TenantPackageType +{ + /// + /// 免费试用套餐。 + /// + Trial = 0, + + /// + /// 标准商业套餐。 + /// + Standard = 1, + + /// + /// 面向成长型商户的高级套餐。 + /// + Professional = 2, + + /// + /// 提供完整能力的旗舰套餐。 + /// + Enterprise = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantQuotaType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantQuotaType.cs new file mode 100644 index 0000000..5392bca --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantQuotaType.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 配额类型,覆盖容量及调用次数。 +/// +public enum TenantQuotaType +{ + /// + /// 门店数量限制。 + /// + StoreCount = 0, + + /// + /// 员工账号数量限制。 + /// + AccountCount = 1, + + /// + /// 存储空间限制。 + /// + Storage = 2, + + /// + /// 短信额度。 + /// + SmsCredits = 3, + + /// + /// 配送订单数量限制。 + /// + DeliveryOrders = 4, + + /// + /// 营销活动并发数量。 + /// + PromotionSlots = 5 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantStatus.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantStatus.cs new file mode 100644 index 0000000..369ed3a --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantStatus.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 租户服务状态。 +/// +public enum TenantStatus +{ + /// + /// 已提交信息,等待审核。 + /// + PendingReview = 0, + + /// + /// 审核通过并正常运营。 + /// + Active = 1, + + /// + /// 因欠费或违规被暂时停用。 + /// + Suspended = 2, + + /// + /// 服务到期尚未续费。 + /// + Expired = 3, + + /// + /// 主动或被动注销,数据进入归档状态。 + /// + Closed = 4 +} diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs new file mode 100644 index 0000000..f7467d5 --- /dev/null +++ b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs @@ -0,0 +1,110 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using System.Threading.RateLimiting; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); + +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => + { + const string partitionKey = "proxy-default"; + return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 100, + Window = TimeSpan.FromSeconds(1), + QueueLimit = 50, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst + }); + }); +}); + +var app = builder.Build(); + +app.UseExceptionHandler(errorApp => +{ + errorApp.Run(async context => + { + var feature = context.Features.Get(); + var traceId = Activity.Current?.Id ?? context.TraceIdentifier; + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + + var payload = new + { + success = false, + code = 500, + message = "Gateway internal error", + traceId + }; + + var logger = context.RequestServices.GetRequiredService().CreateLogger("Gateway"); + logger.LogError(feature?.Error, "网关异常 {TraceId}", traceId); + await context.Response.WriteAsJsonAsync(payload, cancellationToken: context.RequestAborted); + }); +}); + +app.Use(async (context, next) => +{ + var logger = context.RequestServices.GetRequiredService().CreateLogger("Gateway"); + var start = DateTime.UtcNow; + await next(context); + var elapsed = DateTime.UtcNow - start; + logger.LogInformation("Gateway {Method} {Path} => {Status} ({Elapsed} ms)", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + (int)elapsed.TotalMilliseconds); +}); + +app.UseRateLimiter(); + +app.Use(async (context, next) => +{ + // 确保存在请求 ID,便于上下游链路追踪。 + if (!context.Request.Headers.ContainsKey("X-Request-Id")) + { + context.Request.Headers["X-Request-Id"] = Guid.NewGuid().ToString("N"); + } + + // 透传租户与认证头。 + var tenantId = context.Request.Headers["X-Tenant-Id"]; + var tenantCode = context.Request.Headers["X-Tenant-Code"]; + if (!string.IsNullOrWhiteSpace(tenantId)) + { + context.Request.Headers["X-Tenant-Id"] = tenantId; + } + if (!string.IsNullOrWhiteSpace(tenantCode)) + { + context.Request.Headers["X-Tenant-Code"] = tenantCode; + } + + await next(context); +}); + +app.MapReverseProxy(proxyPipeline => +{ + proxyPipeline.Use(async (context, next) => + { + await next().ConfigureAwait(false); + }); +}); + +app.MapGet("/", () => Results.Json(new +{ + Service = "TakeoutSaaS.ApiGateway", + Status = "OK", + Timestamp = DateTimeOffset.UtcNow +})); + +app.Run(); diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/Properties/launchSettings.json b/src/Gateway/TakeoutSaaS.ApiGateway/Properties/launchSettings.json new file mode 100644 index 0000000..3956499 --- /dev/null +++ b/src/Gateway/TakeoutSaaS.ApiGateway/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "TakeoutSaaS.ApiGateway": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:2677;http://localhost:2683" + } + } +} \ No newline at end of file diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj b/src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj new file mode 100644 index 0000000..463d6c9 Binary files /dev/null and b/src/Gateway/TakeoutSaaS.ApiGateway/TakeoutSaaS.ApiGateway.csproj differ diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json new file mode 100644 index 0000000..9c7c501 --- /dev/null +++ b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json @@ -0,0 +1,38 @@ +{ + "ReverseProxy": { + "Routes": [ + { + "RouteId": "admin-route", + "ClusterId": "admin", + "Match": { "Path": "/api/admin/{**catch-all}" } + }, + { + "RouteId": "mini-route", + "ClusterId": "mini", + "Match": { "Path": "/api/mini/{**catch-all}" } + }, + { + "RouteId": "user-route", + "ClusterId": "user", + "Match": { "Path": "/api/user/{**catch-all}" } + } + ], + "Clusters": { + "admin": { + "Destinations": { + "d1": { "Address": "http://localhost:5001/" } + } + }, + "mini": { + "Destinations": { + "d1": { "Address": "http://localhost:5002/" } + } + }, + "user": { + "Destinations": { + "d1": { "Address": "http://localhost:5003/" } + } + } + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs new file mode 100644 index 0000000..02df728 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Domain.Deliveries.Repositories; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Domain.Payments.Repositories; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Infrastructure.App.Options; +using TakeoutSaaS.Infrastructure.App.Persistence; +using TakeoutSaaS.Infrastructure.App.Repositories; +using TakeoutSaaS.Infrastructure.Common.Extensions; +using TakeoutSaaS.Shared.Abstractions.Constants; + +namespace TakeoutSaaS.Infrastructure.App.Extensions; + +/// +/// 业务主库基础设施注册扩展。 +/// +public static class AppServiceCollectionExtensions +{ + /// + /// 注册业务主库 DbContext 与仓储。 + /// + /// 服务集合。 + /// 配置源。 + /// 服务集合。 + public static IServiceCollection AddAppInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddDatabaseInfrastructure(configuration); + services.AddPostgresDbContext(DatabaseConstants.AppDataSource); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddOptions() + .Bind(configuration.GetSection(AppSeedOptions.SectionName)) + .ValidateDataAnnotations(); + + services.AddHostedService(); + + return services; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/AppSeedOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/AppSeedOptions.cs new file mode 100644 index 0000000..b3ec66d --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/AppSeedOptions.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace TakeoutSaaS.Infrastructure.App.Options; + +/// +/// 业务数据种子配置。 +/// +public sealed class AppSeedOptions +{ + /// + /// 配置节名称。 + /// + public const string SectionName = "App:Seed"; + + /// + /// 是否启用业务数据种子。 + /// + public bool Enabled { get; set; } + + /// + /// 默认租户配置。 + /// + public TenantSeedOptions? DefaultTenant { get; set; } + + /// + /// 基础字典分组。 + /// + public List DictionaryGroups { get; set; } = new(); + + /// + /// 系统参数配置。 + /// + public List SystemParameters { get; set; } = new(); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedGroupOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedGroupOptions.cs new file mode 100644 index 0000000..2cec142 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedGroupOptions.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Domain.Dictionary.Enums; + +namespace TakeoutSaaS.Infrastructure.App.Options; + +/// +/// 字典分组种子配置。 +/// +public sealed class DictionarySeedGroupOptions +{ + /// + /// 所属租户,不填则使用默认租户或系统租户。 + /// + public long? TenantId { get; set; } + + /// + /// 分组编码。 + /// + [Required] + [MaxLength(64)] + public string Code { get; set; } = string.Empty; + + /// + /// 分组名称。 + /// + [Required] + [MaxLength(128)] + public string Name { get; set; } = string.Empty; + + /// + /// 分组作用域。 + /// + public DictionaryScope Scope { get; set; } = DictionaryScope.Business; + + /// + /// 描述信息。 + /// + [MaxLength(512)] + public string? Description { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 字典项集合。 + /// + public List Items { get; set; } = new(); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedItemOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedItemOptions.cs new file mode 100644 index 0000000..6d3ea0b --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedItemOptions.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.App.Options; + +/// +/// 字典项种子配置。 +/// +public sealed class DictionarySeedItemOptions +{ + /// + /// 字典项键。 + /// + [Required] + [MaxLength(64)] + public string Key { get; set; } = string.Empty; + + /// + /// 字典项值。 + /// + [Required] + [MaxLength(256)] + public string Value { get; set; } = string.Empty; + + /// + /// 描述。 + /// + [MaxLength(512)] + public string? Description { get; set; } + + /// + /// 排序。 + /// + public int SortOrder { get; set; } = 100; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/SystemParameterSeedOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/SystemParameterSeedOptions.cs new file mode 100644 index 0000000..a9df90b --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/SystemParameterSeedOptions.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Infrastructure.App.Options; + +/// +/// 系统参数种子配置项。 +/// +public sealed class SystemParameterSeedOptions +{ + /// + /// 目标租户,null 时使用默认租户或 0。 + /// + public long? TenantId { get; set; } + + /// + /// 参数键。 + /// + public string Key { get; set; } = string.Empty; + + /// + /// 参数值。 + /// + public string Value { get; set; } = string.Empty; + + /// + /// 说明。 + /// + public string? Description { get; set; } + + /// + /// 排序。 + /// + public int SortOrder { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/TenantSeedOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/TenantSeedOptions.cs new file mode 100644 index 0000000..044e4e6 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/TenantSeedOptions.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.App.Options; + +/// +/// 默认租户种子配置。 +/// +public sealed class TenantSeedOptions +{ + /// + /// 自定义租户标识,不填则自动生成。 + /// + public long TenantId { get; set; } + + /// + /// 租户编码。 + /// + [Required] + [MaxLength(64)] + public string Code { get; set; } = string.Empty; + + /// + /// 租户名称。 + /// + [Required] + [MaxLength(128)] + public string Name { get; set; } = string.Empty; + + /// + /// 租户简称。 + /// + [MaxLength(128)] + public string? ShortName { get; set; } + + /// + /// 联系人姓名。 + /// + [MaxLength(64)] + public string? ContactName { get; set; } + + /// + /// 联系电话。 + /// + [MaxLength(32)] + public string? ContactPhone { get; set; } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs new file mode 100644 index 0000000..4072f0c --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs @@ -0,0 +1,396 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Domain.SystemParameters.Entities; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Infrastructure.App.Options; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Persistence; + +/// +/// 业务数据种子,确保默认租户与基础字典可重复执行。 +/// +/// +/// 初始化种子服务。 +/// +public sealed class AppDataSeeder( + IServiceProvider serviceProvider, + ILogger logger, + IOptions options) : IHostedService +{ + private readonly AppSeedOptions _options = options.Value; + + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + if (!_options.Enabled) + { + logger.LogInformation("AppSeed 未启用,跳过业务数据初始化"); + return; + } + + using var scope = serviceProvider.CreateScope(); + var appDbContext = scope.ServiceProvider.GetRequiredService(); + var dictionaryDbContext = scope.ServiceProvider.GetRequiredService(); + + var defaultTenantId = await EnsureDefaultTenantAsync(appDbContext, cancellationToken); + await EnsureDictionarySeedsAsync(dictionaryDbContext, defaultTenantId, cancellationToken); + + logger.LogInformation("AppSeed 完成业务数据初始化"); + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// 确保默认租户存在。 + /// + private async Task EnsureDefaultTenantAsync(TakeoutAppDbContext dbContext, CancellationToken cancellationToken) + { + var tenantOptions = _options.DefaultTenant; + if (tenantOptions == null || string.IsNullOrWhiteSpace(tenantOptions.Code) || string.IsNullOrWhiteSpace(tenantOptions.Name)) + { + logger.LogInformation("AppSeed 未配置默认租户,跳过租户种子"); + return null; + } + + var code = tenantOptions.Code.Trim(); + var existingTenant = await dbContext.Tenants + .IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.Code == code, cancellationToken); + + if (existingTenant == null) + { + var tenant = new Tenant + { + Id = tenantOptions.TenantId, + Code = code, + Name = tenantOptions.Name.Trim(), + ShortName = tenantOptions.ShortName?.Trim(), + ContactName = tenantOptions.ContactName?.Trim(), + ContactPhone = tenantOptions.ContactPhone?.Trim(), + Status = TenantStatus.Active + }; + + await dbContext.Tenants.AddAsync(tenant, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("AppSeed 已创建默认租户 {TenantCode}", code); + return tenant.Id; + } + + var updated = false; + + if (!string.Equals(existingTenant.Name, tenantOptions.Name, StringComparison.Ordinal)) + { + existingTenant.Name = tenantOptions.Name.Trim(); + updated = true; + } + + if (!string.Equals(existingTenant.ShortName, tenantOptions.ShortName, StringComparison.Ordinal)) + { + existingTenant.ShortName = tenantOptions.ShortName?.Trim(); + updated = true; + } + + if (!string.Equals(existingTenant.ContactName, tenantOptions.ContactName, StringComparison.Ordinal)) + { + existingTenant.ContactName = tenantOptions.ContactName?.Trim(); + updated = true; + } + + if (!string.Equals(existingTenant.ContactPhone, tenantOptions.ContactPhone, StringComparison.Ordinal)) + { + existingTenant.ContactPhone = tenantOptions.ContactPhone?.Trim(); + updated = true; + } + + if (existingTenant.Status != TenantStatus.Active) + { + existingTenant.Status = TenantStatus.Active; + updated = true; + } + + if (updated) + { + dbContext.Tenants.Update(existingTenant); + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("AppSeed 已更新默认租户 {TenantCode}", code); + } + else + { + logger.LogInformation("AppSeed 默认租户 {TenantCode} 已存在且无需更新", code); + } + + return existingTenant.Id; + } + + /// + /// 确保基础字典存在。 + /// + private async Task EnsureDictionarySeedsAsync(DictionaryDbContext dbContext, long? defaultTenantId, CancellationToken cancellationToken) + { + var dictionaryGroups = _options.DictionaryGroups ?? new List(); + var hasDictionaryGroups = dictionaryGroups.Count > 0; + + if (!hasDictionaryGroups) + { + logger.LogInformation("AppSeed 未配置基础字典,跳过字典种子"); + } + + if (hasDictionaryGroups) + { + foreach (var groupOptions in dictionaryGroups) + { + if (string.IsNullOrWhiteSpace(groupOptions.Code) || string.IsNullOrWhiteSpace(groupOptions.Name)) + { + logger.LogWarning("AppSeed 跳过字典分组,Code 或 Name 为空"); + continue; + } + + var tenantId = groupOptions.TenantId ?? defaultTenantId ?? 0; + var code = groupOptions.Code.Trim(); + + var group = await dbContext.DictionaryGroups + .IgnoreQueryFilters() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Code == code, cancellationToken); + + if (group == null) + { + group = new DictionaryGroup + { + Id = 0, + TenantId = tenantId, + Code = code, + Name = groupOptions.Name.Trim(), + Scope = groupOptions.Scope, + Description = groupOptions.Description?.Trim(), + IsEnabled = groupOptions.IsEnabled + }; + + await dbContext.DictionaryGroups.AddAsync(group, cancellationToken); + logger.LogInformation("AppSeed 创建字典分组 {GroupCode} (Tenant: {TenantId})", code, tenantId); + } + else + { + var groupUpdated = false; + + if (!string.Equals(group.Name, groupOptions.Name, StringComparison.Ordinal)) + { + group.Name = groupOptions.Name.Trim(); + groupUpdated = true; + } + + if (!string.Equals(group.Description, groupOptions.Description, StringComparison.Ordinal)) + { + group.Description = groupOptions.Description?.Trim(); + groupUpdated = true; + } + + if (group.Scope != groupOptions.Scope) + { + group.Scope = groupOptions.Scope; + groupUpdated = true; + } + + if (group.IsEnabled != groupOptions.IsEnabled) + { + group.IsEnabled = groupOptions.IsEnabled; + groupUpdated = true; + } + + if (groupUpdated) + { + dbContext.DictionaryGroups.Update(group); + } + } + + await UpsertDictionaryItemsAsync(dbContext, group, groupOptions.Items, tenantId, cancellationToken); + } + } + + await EnsureSystemParametersAsync(dbContext, defaultTenantId, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + + /// + /// 确保系统参数以独立表形式可重复种子。 + /// + private async Task EnsureSystemParametersAsync(DictionaryDbContext dbContext, long? defaultTenantId, CancellationToken cancellationToken) + { + var systemParameters = _options.SystemParameters ?? new List(); + + if (systemParameters.Count == 0) + { + logger.LogInformation("AppSeed 未配置系统参数,跳过系统参数种子"); + return; + } + + var grouped = systemParameters + .Where(x => !string.IsNullOrWhiteSpace(x.Key) && !string.IsNullOrWhiteSpace(x.Value)) + .GroupBy(x => x.TenantId ?? defaultTenantId ?? 0); + + if (!grouped.Any()) + { + logger.LogInformation("AppSeed 系统参数配置为空,跳过系统参数种子"); + return; + } + + foreach (var group in grouped) + { + var tenantId = group.Key; + var existingParameters = await dbContext.SystemParameters + .IgnoreQueryFilters() + .Where(x => x.TenantId == tenantId) + .ToListAsync(cancellationToken); + + foreach (var seed in group) + { + var key = seed.Key.Trim(); + var existing = existingParameters.FirstOrDefault(x => x.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); + + if (existing == null) + { + var parameter = new SystemParameter + { + Id = 0, + TenantId = tenantId, + Key = key, + Value = seed.Value.Trim(), + Description = seed.Description?.Trim(), + SortOrder = seed.SortOrder, + IsEnabled = seed.IsEnabled + }; + + await dbContext.SystemParameters.AddAsync(parameter, cancellationToken); + continue; + } + + var updated = false; + + if (!string.Equals(existing.Value, seed.Value, StringComparison.Ordinal)) + { + existing.Value = seed.Value.Trim(); + updated = true; + } + + if (!string.Equals(existing.Description, seed.Description, StringComparison.Ordinal)) + { + existing.Description = seed.Description?.Trim(); + updated = true; + } + + if (existing.SortOrder != seed.SortOrder) + { + existing.SortOrder = seed.SortOrder; + updated = true; + } + + if (existing.IsEnabled != seed.IsEnabled) + { + existing.IsEnabled = seed.IsEnabled; + updated = true; + } + + if (updated) + { + dbContext.SystemParameters.Update(existing); + } + } + } + } + + /// + /// 合并字典项。 + /// + private static async Task UpsertDictionaryItemsAsync( + DictionaryDbContext dbContext, + DictionaryGroup group, + IEnumerable seedItems, + long tenantId, + CancellationToken cancellationToken) + { + // 确保分组已持久化以获取正确的主键,避免 FK 约束报错。 + if (!dbContext.Entry(group).IsKeySet || group.Id == 0) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + + var materializedItems = seedItems + .Where(item => !string.IsNullOrWhiteSpace(item.Key) && !string.IsNullOrWhiteSpace(item.Value)) + .ToList(); + + if (materializedItems.Count == 0) + { + return; + } + + var existingItems = await dbContext.DictionaryItems + .IgnoreQueryFilters() + .Where(x => x.GroupId == group.Id) + .ToListAsync(cancellationToken); + + foreach (var seed in materializedItems) + { + var key = seed.Key.Trim(); + var existing = existingItems.FirstOrDefault(x => x.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); + + if (existing == null) + { + var newItem = new DictionaryItem + { + Id = 0, + TenantId = tenantId, + GroupId = group.Id, + Key = key, + Value = seed.Value.Trim(), + Description = seed.Description?.Trim(), + SortOrder = seed.SortOrder, + IsEnabled = seed.IsEnabled + }; + + await dbContext.DictionaryItems.AddAsync(newItem, cancellationToken); + continue; + } + + var updated = false; + + if (!string.Equals(existing.Value, seed.Value, StringComparison.Ordinal)) + { + existing.Value = seed.Value.Trim(); + updated = true; + } + + if (!string.Equals(existing.Description, seed.Description, StringComparison.Ordinal)) + { + existing.Description = seed.Description?.Trim(); + updated = true; + } + + if (existing.SortOrder != seed.SortOrder) + { + existing.SortOrder = seed.SortOrder; + updated = true; + } + + if (existing.IsEnabled != seed.IsEnabled) + { + existing.IsEnabled = seed.IsEnabled; + updated = true; + } + + if (updated) + { + dbContext.DictionaryItems.Update(existing); + } + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs new file mode 100644 index 0000000..14d4ff7 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -0,0 +1,994 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TakeoutSaaS.Domain.Analytics.Entities; +using TakeoutSaaS.Domain.Coupons.Entities; +using TakeoutSaaS.Domain.CustomerService.Entities; +using TakeoutSaaS.Domain.Deliveries.Entities; +using TakeoutSaaS.Domain.Distribution.Entities; +using TakeoutSaaS.Domain.Engagement.Entities; +using TakeoutSaaS.Domain.GroupBuying.Entities; +using TakeoutSaaS.Domain.Inventory.Entities; +using TakeoutSaaS.Domain.Membership.Entities; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Navigation.Entities; +using TakeoutSaaS.Domain.Ordering.Entities; +using TakeoutSaaS.Domain.Orders.Entities; +using TakeoutSaaS.Domain.Payments.Entities; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Queues.Entities; +using TakeoutSaaS.Domain.Reservations.Entities; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Infrastructure.Common.Persistence; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Infrastructure.App.Persistence; + +/// +/// 业务主库 DbContext。 +/// +public sealed class TakeoutAppDbContext( + DbContextOptions options, + ITenantProvider tenantProvider, + ICurrentUserAccessor? currentUserAccessor = null, + IIdGenerator? idGenerator = null) + : TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator) +{ + public DbSet Tenants => Set(); + public DbSet TenantPackages => Set(); + public DbSet TenantSubscriptions => Set(); + public DbSet TenantQuotaUsages => Set(); + public DbSet TenantBillingStatements => Set(); + public DbSet TenantNotifications => Set(); + + public DbSet Merchants => Set(); + public DbSet MerchantDocuments => Set(); + public DbSet MerchantContracts => Set(); + public DbSet MerchantStaff => Set(); + + public DbSet Stores => Set(); + public DbSet StoreBusinessHours => Set(); + public DbSet StoreHolidays => Set(); + public DbSet StoreDeliveryZones => Set(); + public DbSet StoreTableAreas => Set(); + public DbSet StoreTables => Set(); + public DbSet StoreEmployeeShifts => Set(); + + public DbSet ProductCategories => Set(); + public DbSet Products => Set(); + public DbSet ProductAttributeGroups => Set(); + public DbSet ProductAttributeOptions => Set(); + public DbSet ProductSkus => Set(); + public DbSet ProductAddonGroups => Set(); + public DbSet ProductAddonOptions => Set(); + public DbSet ProductPricingRules => Set(); + public DbSet ProductMediaAssets => Set(); + + public DbSet InventoryItems => Set(); + public DbSet InventoryAdjustments => Set(); + public DbSet InventoryBatches => Set(); + + public DbSet ShoppingCarts => Set(); + public DbSet CartItems => Set(); + public DbSet CartItemAddons => Set(); + public DbSet CheckoutSessions => Set(); + + public DbSet Orders => Set(); + public DbSet OrderItems => Set(); + public DbSet OrderStatusHistories => Set(); + public DbSet RefundRequests => Set(); + + public DbSet PaymentRecords => Set(); + public DbSet PaymentRefundRecords => Set(); + + public DbSet Reservations => Set(); + public DbSet QueueTickets => Set(); + + public DbSet DeliveryOrders => Set(); + public DbSet DeliveryEvents => Set(); + + public DbSet GroupOrders => Set(); + public DbSet GroupParticipants => Set(); + + public DbSet CouponTemplates => Set(); + public DbSet Coupons => Set(); + public DbSet PromotionCampaigns => Set(); + + public DbSet MemberProfiles => Set(); + public DbSet MemberTiers => Set(); + public DbSet MemberPointLedgers => Set(); + public DbSet MemberGrowthLogs => Set(); + + public DbSet ChatSessions => Set(); + public DbSet ChatMessages => Set(); + public DbSet SupportTickets => Set(); + public DbSet TicketComments => Set(); + + public DbSet AffiliatePartners => Set(); + public DbSet AffiliateOrders => Set(); + public DbSet AffiliatePayouts => Set(); + + public DbSet CheckInCampaigns => Set(); + public DbSet CheckInRecords => Set(); + public DbSet CommunityPosts => Set(); + public DbSet CommunityComments => Set(); + public DbSet CommunityReactions => Set(); + + public DbSet MapLocations => Set(); + public DbSet NavigationRequests => Set(); + + public DbSet MetricDefinitions => Set(); + public DbSet MetricSnapshots => Set(); + public DbSet MetricAlertRules => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + ConfigureTenant(modelBuilder.Entity()); + ConfigureMerchant(modelBuilder.Entity()); + ConfigureStore(modelBuilder.Entity()); + ConfigureTenantPackage(modelBuilder.Entity()); + ConfigureTenantSubscription(modelBuilder.Entity()); + ConfigureTenantQuotaUsage(modelBuilder.Entity()); + ConfigureTenantBilling(modelBuilder.Entity()); + ConfigureTenantNotification(modelBuilder.Entity()); + ConfigureMerchantDocument(modelBuilder.Entity()); + ConfigureMerchantContract(modelBuilder.Entity()); + ConfigureMerchantStaff(modelBuilder.Entity()); + ConfigureStoreBusinessHour(modelBuilder.Entity()); + ConfigureStoreHoliday(modelBuilder.Entity()); + ConfigureStoreDeliveryZone(modelBuilder.Entity()); + ConfigureStoreTableArea(modelBuilder.Entity()); + ConfigureStoreTable(modelBuilder.Entity()); + ConfigureStoreEmployeeShift(modelBuilder.Entity()); + ConfigureProductCategory(modelBuilder.Entity()); + ConfigureProduct(modelBuilder.Entity()); + ConfigureProductAttributeGroup(modelBuilder.Entity()); + ConfigureProductAttributeOption(modelBuilder.Entity()); + ConfigureProductSku(modelBuilder.Entity()); + ConfigureProductAddonGroup(modelBuilder.Entity()); + ConfigureProductAddonOption(modelBuilder.Entity()); + ConfigureProductPricingRule(modelBuilder.Entity()); + ConfigureProductMediaAsset(modelBuilder.Entity()); + ConfigureInventoryItem(modelBuilder.Entity()); + ConfigureInventoryAdjustment(modelBuilder.Entity()); + ConfigureInventoryBatch(modelBuilder.Entity()); + ConfigureShoppingCart(modelBuilder.Entity()); + ConfigureCartItem(modelBuilder.Entity()); + ConfigureCartItemAddon(modelBuilder.Entity()); + ConfigureCheckoutSession(modelBuilder.Entity()); + ConfigureOrder(modelBuilder.Entity()); + ConfigureOrderItem(modelBuilder.Entity()); + ConfigureOrderStatusHistory(modelBuilder.Entity()); + ConfigureRefundRequest(modelBuilder.Entity()); + ConfigurePaymentRecord(modelBuilder.Entity()); + ConfigurePaymentRefundRecord(modelBuilder.Entity()); + ConfigureReservation(modelBuilder.Entity()); + ConfigureQueueTicket(modelBuilder.Entity()); + ConfigureDelivery(modelBuilder.Entity()); + ConfigureDeliveryEvent(modelBuilder.Entity()); + ConfigureGroupOrder(modelBuilder.Entity()); + ConfigureGroupParticipant(modelBuilder.Entity()); + ConfigureCouponTemplate(modelBuilder.Entity()); + ConfigureCoupon(modelBuilder.Entity()); + ConfigurePromotionCampaign(modelBuilder.Entity()); + ConfigureMemberProfile(modelBuilder.Entity()); + ConfigureMemberTier(modelBuilder.Entity()); + ConfigureMemberPointLedger(modelBuilder.Entity()); + ConfigureMemberGrowthLog(modelBuilder.Entity()); + ConfigureChatSession(modelBuilder.Entity()); + ConfigureChatMessage(modelBuilder.Entity()); + ConfigureSupportTicket(modelBuilder.Entity()); + ConfigureTicketComment(modelBuilder.Entity()); + ConfigureAffiliatePartner(modelBuilder.Entity()); + ConfigureAffiliateOrder(modelBuilder.Entity()); + ConfigureAffiliatePayout(modelBuilder.Entity()); + ConfigureCheckInCampaign(modelBuilder.Entity()); + ConfigureCheckInRecord(modelBuilder.Entity()); + ConfigureCommunityPost(modelBuilder.Entity()); + ConfigureCommunityComment(modelBuilder.Entity()); + ConfigureCommunityReaction(modelBuilder.Entity()); + ConfigureMapLocation(modelBuilder.Entity()); + ConfigureNavigationRequest(modelBuilder.Entity()); + ConfigureMetricDefinition(modelBuilder.Entity()); + ConfigureMetricSnapshot(modelBuilder.Entity()); + ConfigureMetricAlertRule(modelBuilder.Entity()); + + ApplyTenantQueryFilters(modelBuilder); + } + + private static void ConfigureTenant(EntityTypeBuilder builder) + { + builder.ToTable("tenants"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Code).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.ShortName).HasMaxLength(64); + builder.Property(x => x.ContactName).HasMaxLength(64); + builder.Property(x => x.ContactPhone).HasMaxLength(32); + builder.Property(x => x.ContactEmail).HasMaxLength(128); + builder.Property(x => x.Industry).HasMaxLength(64); + builder.Property(x => x.LogoUrl).HasMaxLength(256); + builder.Property(x => x.Remarks).HasMaxLength(512); + builder.HasIndex(x => x.Code).IsUnique(); + } + + private static void ConfigureMerchant(EntityTypeBuilder builder) + { + builder.ToTable("merchants"); + builder.HasKey(x => x.Id); + builder.Property(x => x.BrandName).HasMaxLength(128).IsRequired(); + builder.Property(x => x.BrandAlias).HasMaxLength(64); + builder.Property(x => x.LegalPerson).HasMaxLength(64); + builder.Property(x => x.BusinessLicenseNumber).HasMaxLength(64); + builder.Property(x => x.ContactPhone).HasMaxLength(32).IsRequired(); + builder.Property(x => x.ContactEmail).HasMaxLength(128); + builder.Property(x => x.Province).HasMaxLength(64); + builder.Property(x => x.City).HasMaxLength(64); + builder.Property(x => x.District).HasMaxLength(64); + builder.Property(x => x.Address).HasMaxLength(256); + builder.Property(x => x.ReviewRemarks).HasMaxLength(512); + builder.HasIndex(x => x.TenantId); + } + + private static void ConfigureStore(EntityTypeBuilder builder) + { + builder.ToTable("stores"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Code).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Phone).HasMaxLength(32); + builder.Property(x => x.ManagerName).HasMaxLength(64); + builder.Property(x => x.Province).HasMaxLength(64); + builder.Property(x => x.City).HasMaxLength(64); + builder.Property(x => x.District).HasMaxLength(64); + builder.Property(x => x.Address).HasMaxLength(256); + builder.Property(x => x.BusinessHours).HasMaxLength(256); + builder.Property(x => x.Announcement).HasMaxLength(512); + builder.Property(x => x.DeliveryRadiusKm).HasPrecision(6, 2); + builder.HasIndex(x => new { x.TenantId, x.MerchantId }); + builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + } + + private static void ConfigureProductCategory(EntityTypeBuilder builder) + { + builder.ToTable("product_categories"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.StoreId }); + } + + private static void ConfigureProduct(EntityTypeBuilder builder) + { + builder.ToTable("products"); + builder.HasKey(x => x.Id); + builder.Property(x => x.SpuCode).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Subtitle).HasMaxLength(256); + builder.Property(x => x.Unit).HasMaxLength(16); + builder.Property(x => x.Price).HasPrecision(18, 2); + builder.Property(x => x.OriginalPrice).HasPrecision(18, 2); + builder.Property(x => x.CoverImage).HasMaxLength(256); + builder.Property(x => x.GalleryImages).HasMaxLength(1024); + builder.Property(x => x.Description).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.StoreId }); + builder.HasIndex(x => new { x.TenantId, x.SpuCode }).IsUnique(); + } + + private static void ConfigureOrder(EntityTypeBuilder builder) + { + builder.ToTable("orders"); + builder.HasKey(x => x.Id); + builder.Property(x => x.OrderNo).HasMaxLength(32).IsRequired(); + builder.Property(x => x.CustomerName).HasMaxLength(64); + builder.Property(x => x.CustomerPhone).HasMaxLength(32); + builder.Property(x => x.TableNo).HasMaxLength(32); + builder.Property(x => x.QueueNumber).HasMaxLength(32); + builder.Property(x => x.CancelReason).HasMaxLength(256); + builder.Property(x => x.Remark).HasMaxLength(512); + builder.Property(x => x.ItemsAmount).HasPrecision(18, 2); + builder.Property(x => x.DiscountAmount).HasPrecision(18, 2); + builder.Property(x => x.PayableAmount).HasPrecision(18, 2); + builder.Property(x => x.PaidAmount).HasPrecision(18, 2); + builder.HasIndex(x => new { x.TenantId, x.OrderNo }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Status }); + } + + private static void ConfigureOrderItem(EntityTypeBuilder builder) + { + builder.ToTable("order_items"); + builder.HasKey(x => x.Id); + builder.Property(x => x.ProductName).HasMaxLength(128).IsRequired(); + builder.Property(x => x.SkuName).HasMaxLength(128); + builder.Property(x => x.Unit).HasMaxLength(16); + builder.Property(x => x.UnitPrice).HasPrecision(18, 2); + builder.Property(x => x.DiscountAmount).HasPrecision(18, 2); + builder.Property(x => x.SubTotal).HasPrecision(18, 2); + builder.Property(x => x.AttributesJson).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.OrderId }); + builder.HasOne() + .WithMany() + .HasForeignKey(x => x.OrderId) + .OnDelete(DeleteBehavior.Cascade); + } + + private static void ConfigurePaymentRecord(EntityTypeBuilder builder) + { + builder.ToTable("payment_records"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Amount).HasPrecision(18, 2); + builder.Property(x => x.TradeNo).HasMaxLength(64); + builder.Property(x => x.ChannelTransactionId).HasMaxLength(64); + builder.Property(x => x.Remark).HasMaxLength(256); + builder.Property(x => x.Payload).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.OrderId }); + } + + private static void ConfigureReservation(EntityTypeBuilder builder) + { + builder.ToTable("reservations"); + builder.HasKey(x => x.Id); + builder.Property(x => x.ReservationNo).HasMaxLength(32).IsRequired(); + builder.Property(x => x.CustomerName).HasMaxLength(64).IsRequired(); + builder.Property(x => x.CustomerPhone).HasMaxLength(32).IsRequired(); + builder.Property(x => x.TablePreference).HasMaxLength(64); + builder.Property(x => x.Remark).HasMaxLength(512); + builder.Property(x => x.CheckInCode).HasMaxLength(32); + builder.HasIndex(x => new { x.TenantId, x.StoreId }); + builder.HasIndex(x => new { x.TenantId, x.ReservationNo }).IsUnique(); + } + + private static void ConfigureQueueTicket(EntityTypeBuilder builder) + { + builder.ToTable("queue_tickets"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TicketNumber).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Remark).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.StoreId }); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.TicketNumber }).IsUnique(); + } + + private static void ConfigureDelivery(EntityTypeBuilder builder) + { + builder.ToTable("delivery_orders"); + builder.HasKey(x => x.Id); + builder.Property(x => x.ProviderOrderId).HasMaxLength(64); + builder.Property(x => x.DeliveryFee).HasPrecision(18, 2); + builder.Property(x => x.CourierName).HasMaxLength(64); + builder.Property(x => x.CourierPhone).HasMaxLength(32); + builder.Property(x => x.FailureReason).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.OrderId }).IsUnique(); + } + + private static void ConfigureTenantPackage(EntityTypeBuilder builder) + { + builder.ToTable("tenant_packages"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(512); + builder.Property(x => x.FeaturePoliciesJson).HasColumnType("text"); + } + + private static void ConfigureTenantSubscription(EntityTypeBuilder builder) + { + builder.ToTable("tenant_subscriptions"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantPackageId).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.TenantPackageId }); + } + + private static void ConfigureTenantQuotaUsage(EntityTypeBuilder builder) + { + builder.ToTable("tenant_quota_usages"); + builder.HasKey(x => x.Id); + builder.Property(x => x.QuotaType).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.QuotaType }).IsUnique(); + } + + private static void ConfigureTenantBilling(EntityTypeBuilder builder) + { + builder.ToTable("tenant_billing_statements"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StatementNo).HasMaxLength(64).IsRequired(); + builder.Property(x => x.AmountDue).HasPrecision(18, 2); + builder.Property(x => x.AmountPaid).HasPrecision(18, 2); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.LineItemsJson).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.StatementNo }).IsUnique(); + } + + private static void ConfigureTenantNotification(EntityTypeBuilder builder) + { + builder.ToTable("tenant_notifications"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Title).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Message).HasMaxLength(1024).IsRequired(); + builder.Property(x => x.Channel).HasConversion(); + builder.Property(x => x.Severity).HasConversion(); + builder.Property(x => x.MetadataJson).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.Channel, x.SentAt }); + } + + private static void ConfigureMerchantDocument(EntityTypeBuilder builder) + { + builder.ToTable("merchant_documents"); + builder.HasKey(x => x.Id); + builder.Property(x => x.MerchantId).IsRequired(); + builder.Property(x => x.DocumentType).HasConversion(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.FileUrl).HasMaxLength(512).IsRequired(); + builder.Property(x => x.DocumentNumber).HasMaxLength(64); + builder.HasIndex(x => new { x.TenantId, x.MerchantId, x.DocumentType }); + } + + private static void ConfigureMerchantContract(EntityTypeBuilder builder) + { + builder.ToTable("merchant_contracts"); + builder.HasKey(x => x.Id); + builder.Property(x => x.MerchantId).IsRequired(); + builder.Property(x => x.ContractNumber).HasMaxLength(64).IsRequired(); + builder.Property(x => x.FileUrl).HasMaxLength(512).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.TerminationReason).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.MerchantId, x.ContractNumber }).IsUnique(); + } + + private static void ConfigureMerchantStaff(EntityTypeBuilder builder) + { + builder.ToTable("merchant_staff"); + builder.HasKey(x => x.Id); + builder.Property(x => x.MerchantId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Phone).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Email).HasMaxLength(128); + builder.Property(x => x.RoleType).HasConversion(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.PermissionsJson).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.MerchantId, x.Phone }); + } + + private static void ConfigureStoreBusinessHour(EntityTypeBuilder builder) + { + builder.ToTable("store_business_hours"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.HourType).HasConversion(); + builder.Property(x => x.Notes).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.DayOfWeek }); + } + + private static void ConfigureStoreHoliday(EntityTypeBuilder builder) + { + builder.ToTable("store_holidays"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.Reason).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Date }).IsUnique(); + } + + private static void ConfigureStoreDeliveryZone(EntityTypeBuilder builder) + { + builder.ToTable("store_delivery_zones"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.ZoneName).HasMaxLength(64).IsRequired(); + builder.Property(x => x.PolygonGeoJson).HasColumnType("text").IsRequired(); + builder.Property(x => x.MinimumOrderAmount).HasPrecision(18, 2); + builder.Property(x => x.DeliveryFee).HasPrecision(18, 2); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ZoneName }); + } + + private static void ConfigureStoreTableArea(EntityTypeBuilder builder) + { + builder.ToTable("store_table_areas"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name }).IsUnique(); + } + + private static void ConfigureStoreTable(EntityTypeBuilder builder) + { + builder.ToTable("store_tables"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.TableCode).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Tags).HasMaxLength(128); + builder.Property(x => x.QrCodeUrl).HasMaxLength(512); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.TableCode }).IsUnique(); + } + + private static void ConfigureStoreEmployeeShift(EntityTypeBuilder builder) + { + builder.ToTable("store_employee_shifts"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.StaffId).IsRequired(); + builder.Property(x => x.RoleType).HasConversion(); + builder.Property(x => x.Notes).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ShiftDate, x.StaffId }).IsUnique(); + } + + private static void ConfigureProductAttributeGroup(EntityTypeBuilder builder) + { + builder.ToTable("product_attribute_groups"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.SelectionType).HasConversion(); + builder.Property(x => x.StoreId); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name }); + } + + private static void ConfigureProductAttributeOption(EntityTypeBuilder builder) + { + builder.ToTable("product_attribute_options"); + builder.HasKey(x => x.Id); + builder.Property(x => x.AttributeGroupId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.ExtraPrice).HasPrecision(18, 2); + builder.HasIndex(x => new { x.TenantId, x.AttributeGroupId, x.Name }).IsUnique(); + } + + private static void ConfigureProductSku(EntityTypeBuilder builder) + { + builder.ToTable("product_skus"); + builder.HasKey(x => x.Id); + builder.Property(x => x.ProductId).IsRequired(); + builder.Property(x => x.SkuCode).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Barcode).HasMaxLength(64); + builder.Property(x => x.Price).HasPrecision(18, 2); + builder.Property(x => x.OriginalPrice).HasPrecision(18, 2); + builder.Property(x => x.Weight).HasPrecision(10, 3); + builder.Property(x => x.AttributesJson).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.SkuCode }).IsUnique(); + } + + private static void ConfigureProductAddonGroup(EntityTypeBuilder builder) + { + builder.ToTable("product_addon_groups"); + builder.HasKey(x => x.Id); + builder.Property(x => x.ProductId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.SelectionType).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.ProductId, x.Name }); + } + + private static void ConfigureProductAddonOption(EntityTypeBuilder builder) + { + builder.ToTable("product_addon_options"); + builder.HasKey(x => x.Id); + builder.Property(x => x.AddonGroupId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.ExtraPrice).HasPrecision(18, 2); + } + + private static void ConfigureProductPricingRule(EntityTypeBuilder builder) + { + builder.ToTable("product_pricing_rules"); + builder.HasKey(x => x.Id); + builder.Property(x => x.ProductId).IsRequired(); + builder.Property(x => x.RuleType).HasConversion(); + builder.Property(x => x.ConditionsJson).HasColumnType("text").IsRequired(); + builder.Property(x => x.Price).HasPrecision(18, 2); + builder.Property(x => x.WeekdaysJson).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.ProductId, x.RuleType }); + } + + private static void ConfigureProductMediaAsset(EntityTypeBuilder builder) + { + builder.ToTable("product_media_assets"); + builder.HasKey(x => x.Id); + builder.Property(x => x.ProductId).IsRequired(); + builder.Property(x => x.MediaType).HasConversion(); + builder.Property(x => x.Url).HasMaxLength(512).IsRequired(); + builder.Property(x => x.Caption).HasMaxLength(256); + } + + private static void ConfigureInventoryItem(EntityTypeBuilder builder) + { + builder.ToTable("inventory_items"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.ProductSkuId).IsRequired(); + builder.Property(x => x.BatchNumber).HasMaxLength(64); + builder.Property(x => x.Location).HasMaxLength(64); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }); + } + + private static void ConfigureInventoryAdjustment(EntityTypeBuilder builder) + { + builder.ToTable("inventory_adjustments"); + builder.HasKey(x => x.Id); + builder.Property(x => x.InventoryItemId).IsRequired(); + builder.Property(x => x.AdjustmentType).HasConversion(); + builder.Property(x => x.Reason).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.InventoryItemId, x.OccurredAt }); + } + + private static void ConfigureInventoryBatch(EntityTypeBuilder builder) + { + builder.ToTable("inventory_batches"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.ProductSkuId).IsRequired(); + builder.Property(x => x.BatchNumber).HasMaxLength(64).IsRequired(); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }).IsUnique(); + } + + private static void ConfigureShoppingCart(EntityTypeBuilder builder) + { + builder.ToTable("shopping_carts"); + builder.HasKey(x => x.Id); + builder.Property(x => x.UserId).IsRequired(); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.TableContext).HasMaxLength(64); + builder.Property(x => x.DeliveryPreference).HasMaxLength(32); + builder.HasIndex(x => new { x.TenantId, x.UserId, x.StoreId }).IsUnique(); + } + + private static void ConfigureCartItem(EntityTypeBuilder builder) + { + builder.ToTable("cart_items"); + builder.HasKey(x => x.Id); + builder.Property(x => x.ShoppingCartId).IsRequired(); + builder.Property(x => x.ProductId).IsRequired(); + builder.Property(x => x.ProductName).HasMaxLength(128).IsRequired(); + builder.Property(x => x.UnitPrice).HasPrecision(18, 2); + builder.Property(x => x.Remark).HasMaxLength(256); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.AttributesJson).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.ShoppingCartId }); + } + + private static void ConfigureCartItemAddon(EntityTypeBuilder builder) + { + builder.ToTable("cart_item_addons"); + builder.HasKey(x => x.Id); + builder.Property(x => x.CartItemId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.ExtraPrice).HasPrecision(18, 2); + } + + private static void ConfigureCheckoutSession(EntityTypeBuilder builder) + { + builder.ToTable("checkout_sessions"); + builder.HasKey(x => x.Id); + builder.Property(x => x.UserId).IsRequired(); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.SessionToken).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.ValidationResultJson).HasColumnType("text").IsRequired(); + builder.HasIndex(x => new { x.TenantId, x.SessionToken }).IsUnique(); + } + + private static void ConfigureOrderStatusHistory(EntityTypeBuilder builder) + { + builder.ToTable("order_status_histories"); + builder.HasKey(x => x.Id); + builder.Property(x => x.OrderId).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.Notes).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.OrderId, x.OccurredAt }); + } + + private static void ConfigureRefundRequest(EntityTypeBuilder builder) + { + builder.ToTable("refund_requests"); + builder.HasKey(x => x.Id); + builder.Property(x => x.OrderId).IsRequired(); + builder.Property(x => x.RefundNo).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Amount).HasPrecision(18, 2); + builder.Property(x => x.Reason).HasMaxLength(256).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.ReviewNotes).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.RefundNo }).IsUnique(); + } + + private static void ConfigurePaymentRefundRecord(EntityTypeBuilder builder) + { + builder.ToTable("payment_refund_records"); + builder.HasKey(x => x.Id); + builder.Property(x => x.PaymentRecordId).IsRequired(); + builder.Property(x => x.OrderId).IsRequired(); + builder.Property(x => x.Amount).HasPrecision(18, 2); + builder.Property(x => x.ChannelRefundId).HasMaxLength(64); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.Payload).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.PaymentRecordId }); + } + + private static void ConfigureDeliveryEvent(EntityTypeBuilder builder) + { + builder.ToTable("delivery_events"); + builder.HasKey(x => x.Id); + builder.Property(x => x.DeliveryOrderId).IsRequired(); + builder.Property(x => x.EventType).HasConversion(); + builder.Property(x => x.Message).HasMaxLength(256); + builder.Property(x => x.Payload).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.DeliveryOrderId, x.EventType }); + } + + private static void ConfigureGroupOrder(EntityTypeBuilder builder) + { + builder.ToTable("group_orders"); + builder.HasKey(x => x.Id); + builder.Property(x => x.GroupOrderNo).HasMaxLength(32).IsRequired(); + builder.Property(x => x.GroupPrice).HasPrecision(18, 2); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.GroupOrderNo }).IsUnique(); + } + + private static void ConfigureGroupParticipant(EntityTypeBuilder builder) + { + builder.ToTable("group_participants"); + builder.HasKey(x => x.Id); + builder.Property(x => x.GroupOrderId).IsRequired(); + builder.Property(x => x.OrderId).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.GroupOrderId, x.UserId }).IsUnique(); + } + + private static void ConfigureCouponTemplate(EntityTypeBuilder builder) + { + builder.ToTable("coupon_templates"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.CouponType).HasConversion(); + builder.Property(x => x.Description).HasMaxLength(512); + builder.Property(x => x.TotalQuantity); + builder.Property(x => x.StoreScopeJson).HasColumnType("text"); + builder.Property(x => x.ProductScopeJson).HasColumnType("text"); + builder.Property(x => x.ChannelsJson).HasColumnType("text"); + builder.Property(x => x.Status).HasConversion(); + } + + private static void ConfigureCoupon(EntityTypeBuilder builder) + { + builder.ToTable("coupons"); + builder.HasKey(x => x.Id); + builder.Property(x => x.CouponTemplateId).IsRequired(); + builder.Property(x => x.Code).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + } + + private static void ConfigurePromotionCampaign(EntityTypeBuilder builder) + { + builder.ToTable("promotion_campaigns"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.PromotionType).HasConversion(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.RulesJson).HasColumnType("text").IsRequired(); + builder.Property(x => x.AudienceDescription).HasMaxLength(512); + builder.Property(x => x.BannerUrl).HasMaxLength(512); + } + + private static void ConfigureMemberProfile(EntityTypeBuilder builder) + { + builder.ToTable("member_profiles"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Mobile).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Nickname).HasMaxLength(64); + builder.Property(x => x.AvatarUrl).HasMaxLength(256); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.Mobile }).IsUnique(); + } + + private static void ConfigureMemberTier(EntityTypeBuilder builder) + { + builder.ToTable("member_tiers"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.BenefitsJson).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.Name }).IsUnique(); + } + + private static void ConfigureMemberPointLedger(EntityTypeBuilder builder) + { + builder.ToTable("member_point_ledgers"); + builder.HasKey(x => x.Id); + builder.Property(x => x.MemberId).IsRequired(); + builder.Property(x => x.Reason).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.MemberId, x.OccurredAt }); + } + + private static void ConfigureMemberGrowthLog(EntityTypeBuilder builder) + { + builder.ToTable("member_growth_logs"); + builder.HasKey(x => x.Id); + builder.Property(x => x.MemberId).IsRequired(); + builder.Property(x => x.Notes).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.MemberId, x.OccurredAt }); + } + + private static void ConfigureChatSession(EntityTypeBuilder builder) + { + builder.ToTable("chat_sessions"); + builder.HasKey(x => x.Id); + builder.Property(x => x.SessionCode).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.SessionCode }).IsUnique(); + } + + private static void ConfigureChatMessage(EntityTypeBuilder builder) + { + builder.ToTable("chat_messages"); + builder.HasKey(x => x.Id); + builder.Property(x => x.ChatSessionId).IsRequired(); + builder.Property(x => x.SenderType).HasConversion(); + builder.Property(x => x.ContentType).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Content).HasMaxLength(1024).IsRequired(); + builder.HasIndex(x => new { x.TenantId, x.ChatSessionId, x.CreatedAt }); + } + + private static void ConfigureSupportTicket(EntityTypeBuilder builder) + { + builder.ToTable("support_tickets"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TicketNo).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Subject).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Description).HasColumnType("text").IsRequired(); + builder.Property(x => x.Priority).HasConversion(); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.TicketNo }).IsUnique(); + } + + private static void ConfigureTicketComment(EntityTypeBuilder builder) + { + builder.ToTable("ticket_comments"); + builder.HasKey(x => x.Id); + builder.Property(x => x.SupportTicketId).IsRequired(); + builder.Property(x => x.Content).HasMaxLength(1024).IsRequired(); + builder.Property(x => x.AttachmentsJson).HasColumnType("text"); + builder.HasIndex(x => new { x.TenantId, x.SupportTicketId }); + } + + private static void ConfigureAffiliatePartner(EntityTypeBuilder builder) + { + builder.ToTable("affiliate_partners"); + builder.HasKey(x => x.Id); + builder.Property(x => x.DisplayName).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Phone).HasMaxLength(32); + builder.Property(x => x.ChannelType).HasConversion(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.Remarks).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.DisplayName }); + } + + private static void ConfigureAffiliateOrder(EntityTypeBuilder builder) + { + builder.ToTable("affiliate_orders"); + builder.HasKey(x => x.Id); + builder.Property(x => x.AffiliatePartnerId).IsRequired(); + builder.Property(x => x.OrderId).IsRequired(); + builder.Property(x => x.OrderAmount).HasPrecision(18, 2); + builder.Property(x => x.EstimatedCommission).HasPrecision(18, 2); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.AffiliatePartnerId, x.OrderId }).IsUnique(); + } + + private static void ConfigureAffiliatePayout(EntityTypeBuilder builder) + { + builder.ToTable("affiliate_payouts"); + builder.HasKey(x => x.Id); + builder.Property(x => x.AffiliatePartnerId).IsRequired(); + builder.Property(x => x.Period).HasMaxLength(32).IsRequired(); + builder.Property(x => x.Amount).HasPrecision(18, 2); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.Remarks).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.AffiliatePartnerId, x.Period }).IsUnique(); + } + + private static void ConfigureCheckInCampaign(EntityTypeBuilder builder) + { + builder.ToTable("checkin_campaigns"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(512); + builder.Property(x => x.RewardsJson).HasColumnType("text").IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.Name }); + } + + private static void ConfigureCheckInRecord(EntityTypeBuilder builder) + { + builder.ToTable("checkin_records"); + builder.HasKey(x => x.Id); + builder.Property(x => x.CheckInCampaignId).IsRequired(); + builder.Property(x => x.UserId).IsRequired(); + builder.Property(x => x.RewardJson).HasColumnType("text").IsRequired(); + builder.HasIndex(x => new { x.TenantId, x.CheckInCampaignId, x.UserId, x.CheckInDate }).IsUnique(); + } + + private static void ConfigureCommunityPost(EntityTypeBuilder builder) + { + builder.ToTable("community_posts"); + builder.HasKey(x => x.Id); + builder.Property(x => x.AuthorUserId).IsRequired(); + builder.Property(x => x.Title).HasMaxLength(128); + builder.Property(x => x.Content).HasColumnType("text").IsRequired(); + builder.Property(x => x.MediaJson).HasColumnType("text"); + builder.Property(x => x.Status).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.AuthorUserId, x.CreatedAt }); + } + + private static void ConfigureCommunityComment(EntityTypeBuilder builder) + { + builder.ToTable("community_comments"); + builder.HasKey(x => x.Id); + builder.Property(x => x.PostId).IsRequired(); + builder.Property(x => x.Content).HasMaxLength(512).IsRequired(); + builder.HasIndex(x => new { x.TenantId, x.PostId, x.CreatedAt }); + } + + private static void ConfigureCommunityReaction(EntityTypeBuilder builder) + { + builder.ToTable("community_reactions"); + builder.HasKey(x => x.Id); + builder.Property(x => x.PostId).IsRequired(); + builder.Property(x => x.ReactionType).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.PostId, x.UserId }).IsUnique(); + } + + private static void ConfigureMapLocation(EntityTypeBuilder builder) + { + builder.ToTable("map_locations"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Address).HasMaxLength(256).IsRequired(); + builder.Property(x => x.Landmark).HasMaxLength(128); + builder.HasIndex(x => new { x.TenantId, x.StoreId }); + } + + private static void ConfigureNavigationRequest(EntityTypeBuilder builder) + { + builder.ToTable("navigation_requests"); + builder.HasKey(x => x.Id); + builder.Property(x => x.UserId).IsRequired(); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.Channel).HasConversion(); + builder.Property(x => x.TargetApp).HasConversion(); + builder.HasIndex(x => new { x.TenantId, x.UserId, x.StoreId, x.RequestedAt }); + } + + private static void ConfigureMetricDefinition(EntityTypeBuilder builder) + { + builder.ToTable("metric_definitions"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Code).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(512); + builder.Property(x => x.DimensionsJson).HasColumnType("text"); + builder.Property(x => x.DefaultAggregation).HasMaxLength(32).IsRequired(); + builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + } + + private static void ConfigureMetricSnapshot(EntityTypeBuilder builder) + { + builder.ToTable("metric_snapshots"); + builder.HasKey(x => x.Id); + builder.Property(x => x.MetricDefinitionId).IsRequired(); + builder.Property(x => x.DimensionKey).HasMaxLength(256).IsRequired(); + builder.Property(x => x.Value).HasPrecision(18, 4); + builder.HasIndex(x => new { x.TenantId, x.MetricDefinitionId, x.DimensionKey, x.WindowStart, x.WindowEnd }).IsUnique(); + } + + private static void ConfigureMetricAlertRule(EntityTypeBuilder builder) + { + builder.ToTable("metric_alert_rules"); + builder.HasKey(x => x.Id); + builder.Property(x => x.MetricDefinitionId).IsRequired(); + builder.Property(x => x.ConditionJson).HasColumnType("text").IsRequired(); + builder.Property(x => x.Severity).HasConversion(); + builder.Property(x => x.NotificationChannels).HasMaxLength(256); + builder.HasIndex(x => new { x.TenantId, x.MetricDefinitionId, x.Severity }); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDesignTimeDbContextFactory.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDesignTimeDbContextFactory.cs new file mode 100644 index 0000000..e29572b --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDesignTimeDbContextFactory.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Infrastructure.Common.Persistence.DesignTime; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Infrastructure.App.Persistence; + +/// +/// 设计时工厂,供 EF CLI 使用。 +/// +internal sealed class TakeoutAppDesignTimeDbContextFactory + : DesignTimeDbContextFactoryBase +{ + public TakeoutAppDesignTimeDbContextFactory() + : base(DatabaseConstants.AppDataSource, "TAKEOUTSAAS_APP_CONNECTION") + { + } + + protected override TakeoutAppDbContext CreateContext( + DbContextOptions options, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + => new(options, tenantProvider, currentUserAccessor); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs new file mode 100644 index 0000000..34917c7 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs @@ -0,0 +1,120 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Deliveries.Entities; +using TakeoutSaaS.Domain.Deliveries.Enums; +using TakeoutSaaS.Domain.Deliveries.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 配送聚合的 EF Core 仓储实现。 +/// +/// +/// 初始化仓储。 +/// +public sealed class EfDeliveryRepository(TakeoutAppDbContext context) : IDeliveryRepository +{ + + + /// + public Task FindByIdAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default) + { + return context.DeliveryOrders + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.Id == deliveryOrderId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) + { + return context.DeliveryOrders + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.OrderId == orderId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> GetEventsAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default) + { + var events = await context.DeliveryEvents + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.DeliveryOrderId == deliveryOrderId) + .OrderBy(x => x.CreatedAt) + .ToListAsync(cancellationToken); + + return events; + } + + /// + public Task AddDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default) + { + return context.DeliveryOrders.AddAsync(deliveryOrder, cancellationToken).AsTask(); + } + + /// + public Task AddEventAsync(DeliveryEvent deliveryEvent, CancellationToken cancellationToken = default) + { + return context.DeliveryEvents.AddAsync(deliveryEvent, cancellationToken).AsTask(); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } + + /// + public async Task> SearchAsync(long tenantId, DeliveryStatus? status, long? orderId, CancellationToken cancellationToken = default) + { + var query = context.DeliveryOrders + .AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + if (orderId.HasValue) + { + query = query.Where(x => x.OrderId == orderId.Value); + } + + return await query + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public Task UpdateDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default) + { + context.DeliveryOrders.Update(deliveryOrder); + return Task.CompletedTask; + } + + /// + public async Task DeleteDeliveryOrderAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default) + { + var events = await context.DeliveryEvents + .Where(x => x.TenantId == tenantId && x.DeliveryOrderId == deliveryOrderId) + .ToListAsync(cancellationToken); + + if (events.Count > 0) + { + context.DeliveryEvents.RemoveRange(events); + } + + var existing = await context.DeliveryOrders + .Where(x => x.TenantId == tenantId && x.Id == deliveryOrderId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing == null) + { + return; + } + + context.DeliveryOrders.Remove(existing); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs new file mode 100644 index 0000000..6158f92 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs @@ -0,0 +1,133 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 商户聚合的 EF Core 仓储实现。 +/// +/// +/// 初始化仓储。 +/// +public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchantRepository +{ + + + /// + public Task FindByIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) + { + return context.Merchants + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.Id == merchantId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> SearchAsync(long tenantId, MerchantStatus? status, CancellationToken cancellationToken = default) + { + var query = context.Merchants + .AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + return await query + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public async Task> GetStaffAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) + { + var staffs = await context.MerchantStaff + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.MerchantId == merchantId) + .OrderBy(x => x.Name) + .ToListAsync(cancellationToken); + + return staffs; + } + + /// + public async Task> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) + { + var contracts = await context.MerchantContracts + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.MerchantId == merchantId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + + return contracts; + } + + /// + public async Task> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) + { + var documents = await context.MerchantDocuments + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.MerchantId == merchantId) + .OrderBy(x => x.CreatedAt) + .ToListAsync(cancellationToken); + + return documents; + } + + /// + public Task AddMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default) + { + return context.Merchants.AddAsync(merchant, cancellationToken).AsTask(); + } + + /// + public Task AddStaffAsync(MerchantStaff staff, CancellationToken cancellationToken = default) + { + return context.MerchantStaff.AddAsync(staff, cancellationToken).AsTask(); + } + + /// + public Task AddContractAsync(MerchantContract contract, CancellationToken cancellationToken = default) + { + return context.MerchantContracts.AddAsync(contract, cancellationToken).AsTask(); + } + + /// + public Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default) + { + return context.MerchantDocuments.AddAsync(document, cancellationToken).AsTask(); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } + + /// + public Task UpdateMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default) + { + context.Merchants.Update(merchant); + return Task.CompletedTask; + } + + /// + public async Task DeleteMerchantAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.Merchants + .Where(x => x.TenantId == tenantId && x.Id == merchantId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing == null) + { + return; + } + + context.Merchants.Remove(existing); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs new file mode 100644 index 0000000..c73a185 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs @@ -0,0 +1,173 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Orders.Entities; +using TakeoutSaaS.Domain.Orders.Enums; +using TakeoutSaaS.Domain.Orders.Repositories; +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 订单聚合的 EF Core 仓储实现。 +/// +/// +/// 初始化仓储。 +/// +public sealed class EfOrderRepository(TakeoutAppDbContext context) : IOrderRepository +{ + + + /// + public Task FindByIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) + { + return context.Orders + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.Id == orderId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task FindByOrderNoAsync(string orderNo, long tenantId, CancellationToken cancellationToken = default) + { + return context.Orders + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.OrderNo == orderNo) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> SearchAsync(long tenantId, OrderStatus? status, PaymentStatus? paymentStatus, CancellationToken cancellationToken = default) + { + var query = context.Orders + .AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + if (paymentStatus.HasValue) + { + query = query.Where(x => x.PaymentStatus == paymentStatus.Value); + } + + var orders = await query + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + + return orders; + } + + /// + public async Task> GetItemsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) + { + var items = await context.OrderItems + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.OrderId == orderId) + .OrderBy(x => x.Id) + .ToListAsync(cancellationToken); + + return items; + } + + /// + public async Task> GetStatusHistoryAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) + { + var histories = await context.OrderStatusHistories + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.OrderId == orderId) + .OrderBy(x => x.CreatedAt) + .ToListAsync(cancellationToken); + + return histories; + } + + /// + public async Task> GetRefundsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) + { + var refunds = await context.RefundRequests + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.OrderId == orderId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + + return refunds; + } + + /// + public Task AddOrderAsync(Order order, CancellationToken cancellationToken = default) + { + return context.Orders.AddAsync(order, cancellationToken).AsTask(); + } + + /// + public Task AddItemsAsync(IEnumerable items, CancellationToken cancellationToken = default) + { + return context.OrderItems.AddRangeAsync(items, cancellationToken); + } + + /// + public Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default) + { + return context.OrderStatusHistories.AddAsync(history, cancellationToken).AsTask(); + } + + /// + public Task AddRefundAsync(RefundRequest refund, CancellationToken cancellationToken = default) + { + return context.RefundRequests.AddAsync(refund, cancellationToken).AsTask(); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } + + /// + public Task UpdateOrderAsync(Order order, CancellationToken cancellationToken = default) + { + context.Orders.Update(order); + return Task.CompletedTask; + } + + /// + public async Task DeleteOrderAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) + { + var items = await context.OrderItems + .Where(x => x.TenantId == tenantId && x.OrderId == orderId) + .ToListAsync(cancellationToken); + if (items.Count > 0) + { + context.OrderItems.RemoveRange(items); + } + + var histories = await context.OrderStatusHistories + .Where(x => x.TenantId == tenantId && x.OrderId == orderId) + .ToListAsync(cancellationToken); + if (histories.Count > 0) + { + context.OrderStatusHistories.RemoveRange(histories); + } + + var refunds = await context.RefundRequests + .Where(x => x.TenantId == tenantId && x.OrderId == orderId) + .ToListAsync(cancellationToken); + if (refunds.Count > 0) + { + context.RefundRequests.RemoveRange(refunds); + } + + var existing = await context.Orders + .Where(x => x.TenantId == tenantId && x.Id == orderId) + .FirstOrDefaultAsync(cancellationToken); + if (existing == null) + { + return; + } + + context.Orders.Remove(existing); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs new file mode 100644 index 0000000..90be2a6 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs @@ -0,0 +1,113 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Payments.Entities; +using TakeoutSaaS.Domain.Payments.Enums; +using TakeoutSaaS.Domain.Payments.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 支付记录的 EF Core 仓储实现。 +/// +/// +/// 初始化仓储。 +/// +public sealed class EfPaymentRepository(TakeoutAppDbContext context) : IPaymentRepository +{ + + + /// + public Task FindByIdAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default) + { + return context.PaymentRecords + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.Id == paymentId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) + { + return context.PaymentRecords + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.OrderId == orderId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> GetRefundsAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default) + { + var refunds = await context.PaymentRefundRecords + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.PaymentRecordId == paymentId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + + return refunds; + } + + /// + public Task AddPaymentAsync(PaymentRecord payment, CancellationToken cancellationToken = default) + { + return context.PaymentRecords.AddAsync(payment, cancellationToken).AsTask(); + } + + /// + public Task AddRefundAsync(PaymentRefundRecord refund, CancellationToken cancellationToken = default) + { + return context.PaymentRefundRecords.AddAsync(refund, cancellationToken).AsTask(); + } + + /// + public async Task> SearchAsync(long tenantId, PaymentStatus? status, CancellationToken cancellationToken = default) + { + var query = context.PaymentRecords + .AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + return await query + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } + + /// + public Task UpdatePaymentAsync(PaymentRecord payment, CancellationToken cancellationToken = default) + { + context.PaymentRecords.Update(payment); + return Task.CompletedTask; + } + + /// + public async Task DeletePaymentAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default) + { + var refunds = await context.PaymentRefundRecords + .Where(x => x.TenantId == tenantId && x.PaymentRecordId == paymentId) + .ToListAsync(cancellationToken); + if (refunds.Count > 0) + { + context.PaymentRefundRecords.RemoveRange(refunds); + } + + var existing = await context.PaymentRecords + .Where(x => x.TenantId == tenantId && x.Id == paymentId) + .FirstOrDefaultAsync(cancellationToken); + if (existing == null) + { + return; + } + + context.PaymentRecords.Remove(existing); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs new file mode 100644 index 0000000..65666bb --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs @@ -0,0 +1,381 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 商品聚合的 EF Core 仓储实现。 +/// +/// +/// 初始化仓储。 +/// +public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductRepository +{ + + + /// + public Task FindByIdAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + return context.Products + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.Id == productId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default) + { + var query = context.Products + .AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (categoryId.HasValue) + { + query = query.Where(x => x.CategoryId == categoryId.Value); + } + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + var products = await query + .OrderBy(x => x.Name) + .ToListAsync(cancellationToken); + + return products; + } + + /// + public async Task> GetCategoriesAsync(long tenantId, CancellationToken cancellationToken = default) + { + var categories = await context.ProductCategories + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + return categories; + } + + /// + public async Task> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + var skus = await context.ProductSkus + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.ProductId == productId) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + return skus; + } + + /// + public async Task> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + var groups = await context.ProductAddonGroups + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.ProductId == productId) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + return groups; + } + + /// + public async Task> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + var groupIds = await context.ProductAddonGroups + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.ProductId == productId) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + if (groupIds.Count == 0) + { + return Array.Empty(); + } + + var options = await context.ProductAddonOptions + .AsNoTracking() + .Where(x => x.TenantId == tenantId && groupIds.Contains(x.AddonGroupId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + return options; + } + + /// + public async Task> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + var groups = await context.ProductAttributeGroups + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.ProductId == productId) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + return groups; + } + + /// + public async Task> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + var groupIds = await context.ProductAttributeGroups + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.ProductId == productId) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + if (groupIds.Count == 0) + { + return Array.Empty(); + } + + var options = await context.ProductAttributeOptions + .AsNoTracking() + .Where(x => x.TenantId == tenantId && groupIds.Contains(x.AttributeGroupId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + return options; + } + + /// + public async Task> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + var assets = await context.ProductMediaAssets + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.ProductId == productId) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + return assets; + } + + /// + public async Task> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + var rules = await context.ProductPricingRules + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.ProductId == productId) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + return rules; + } + + /// + public Task AddCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default) + { + return context.ProductCategories.AddAsync(category, cancellationToken).AsTask(); + } + + /// + public Task AddProductAsync(Product product, CancellationToken cancellationToken = default) + { + return context.Products.AddAsync(product, cancellationToken).AsTask(); + } + + /// + public Task AddSkusAsync(IEnumerable skus, CancellationToken cancellationToken = default) + { + return context.ProductSkus.AddRangeAsync(skus, cancellationToken); + } + + /// + public Task AddAddonGroupsAsync(IEnumerable groups, IEnumerable options, CancellationToken cancellationToken = default) + { + var addGroupsTask = context.ProductAddonGroups.AddRangeAsync(groups, cancellationToken); + var addOptionsTask = context.ProductAddonOptions.AddRangeAsync(options, cancellationToken); + return Task.WhenAll(addGroupsTask, addOptionsTask); + } + + /// + public Task AddAttributeGroupsAsync(IEnumerable groups, IEnumerable options, CancellationToken cancellationToken = default) + { + var addGroupsTask = context.ProductAttributeGroups.AddRangeAsync(groups, cancellationToken); + var addOptionsTask = context.ProductAttributeOptions.AddRangeAsync(options, cancellationToken); + return Task.WhenAll(addGroupsTask, addOptionsTask); + } + + /// + public Task AddMediaAssetsAsync(IEnumerable assets, CancellationToken cancellationToken = default) + { + return context.ProductMediaAssets.AddRangeAsync(assets, cancellationToken); + } + + /// + public Task AddPricingRulesAsync(IEnumerable rules, CancellationToken cancellationToken = default) + { + return context.ProductPricingRules.AddRangeAsync(rules, cancellationToken); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } + + /// + public Task UpdateProductAsync(Product product, CancellationToken cancellationToken = default) + { + context.Products.Update(product); + return Task.CompletedTask; + } + + /// + public async Task DeleteProductAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + await RemovePricingRulesAsync(productId, tenantId, cancellationToken); + await RemoveMediaAssetsAsync(productId, tenantId, cancellationToken); + await RemoveAttributeGroupsAsync(productId, tenantId, cancellationToken); + await RemoveAddonGroupsAsync(productId, tenantId, cancellationToken); + await RemoveSkusAsync(productId, tenantId, cancellationToken); + + var existing = await context.Products + .Where(x => x.TenantId == tenantId && x.Id == productId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing == null) + { + return; + } + + context.Products.Remove(existing); + } + + /// + public Task UpdateCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default) + { + context.ProductCategories.Update(category); + return Task.CompletedTask; + } + + /// + public async Task DeleteCategoryAsync(long categoryId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.ProductCategories + .Where(x => x.TenantId == tenantId && x.Id == categoryId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing == null) + { + return; + } + + context.ProductCategories.Remove(existing); + } + + /// + public async Task RemoveSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + var skus = await context.ProductSkus + .Where(x => x.TenantId == tenantId && x.ProductId == productId) + .ToListAsync(cancellationToken); + + if (skus.Count == 0) + { + return; + } + + context.ProductSkus.RemoveRange(skus); + } + + /// + public async Task RemoveAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + var groupIds = await context.ProductAddonGroups + .Where(x => x.TenantId == tenantId && x.ProductId == productId) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + if (groupIds.Count == 0) + { + return; + } + + var options = await context.ProductAddonOptions + .Where(x => x.TenantId == tenantId && groupIds.Contains(x.AddonGroupId)) + .ToListAsync(cancellationToken); + + if (options.Count > 0) + { + context.ProductAddonOptions.RemoveRange(options); + } + + var groups = await context.ProductAddonGroups + .Where(x => groupIds.Contains(x.Id)) + .ToListAsync(cancellationToken); + + if (groups.Count > 0) + { + context.ProductAddonGroups.RemoveRange(groups); + } + } + + /// + public async Task RemoveAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + var groupIds = await context.ProductAttributeGroups + .Where(x => x.TenantId == tenantId && x.ProductId == productId) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + if (groupIds.Count == 0) + { + return; + } + + var options = await context.ProductAttributeOptions + .Where(x => x.TenantId == tenantId && groupIds.Contains(x.AttributeGroupId)) + .ToListAsync(cancellationToken); + + if (options.Count > 0) + { + context.ProductAttributeOptions.RemoveRange(options); + } + + var groups = await context.ProductAttributeGroups + .Where(x => groupIds.Contains(x.Id)) + .ToListAsync(cancellationToken); + + if (groups.Count > 0) + { + context.ProductAttributeGroups.RemoveRange(groups); + } + } + + /// + public async Task RemoveMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + var assets = await context.ProductMediaAssets + .Where(x => x.TenantId == tenantId && x.ProductId == productId) + .ToListAsync(cancellationToken); + + if (assets.Count == 0) + { + return; + } + + context.ProductMediaAssets.RemoveRange(assets); + } + + /// + public async Task RemovePricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default) + { + var rules = await context.ProductPricingRules + .Where(x => x.TenantId == tenantId && x.ProductId == productId) + .ToListAsync(cancellationToken); + + if (rules.Count == 0) + { + return; + } + + context.ProductPricingRules.RemoveRange(rules); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs new file mode 100644 index 0000000..3a0934a --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs @@ -0,0 +1,191 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 门店聚合的 EF Core 仓储实现。 +/// +/// +/// 初始化仓储。 +/// +public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepository +{ + + + /// + public Task FindByIdAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + return context.Stores + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.Id == storeId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> SearchAsync(long tenantId, StoreStatus? status, CancellationToken cancellationToken = default) + { + var query = context.Stores + .AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + var stores = await query + .OrderBy(x => x.Name) + .ToListAsync(cancellationToken); + + return stores; + } + + /// + public async Task> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var hours = await context.StoreBusinessHours + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.DayOfWeek) + .ThenBy(x => x.StartTime) + .ToListAsync(cancellationToken); + + return hours; + } + + /// + public async Task> GetDeliveryZonesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var zones = await context.StoreDeliveryZones + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + return zones; + } + + /// + public async Task> GetHolidaysAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var holidays = await context.StoreHolidays + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.Date) + .ToListAsync(cancellationToken); + + return holidays; + } + + /// + public async Task> GetTableAreasAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var areas = await context.StoreTableAreas + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + + return areas; + } + + /// + public async Task> GetTablesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var tables = await context.StoreTables + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.TableCode) + .ToListAsync(cancellationToken); + + return tables; + } + + /// + public async Task> GetShiftsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var shifts = await context.StoreEmployeeShifts + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.ShiftDate) + .ThenBy(x => x.StartTime) + .ToListAsync(cancellationToken); + + return shifts; + } + + /// + public Task AddStoreAsync(Store store, CancellationToken cancellationToken = default) + { + return context.Stores.AddAsync(store, cancellationToken).AsTask(); + } + + /// + public Task AddBusinessHoursAsync(IEnumerable hours, CancellationToken cancellationToken = default) + { + return context.StoreBusinessHours.AddRangeAsync(hours, cancellationToken); + } + + /// + public Task AddDeliveryZonesAsync(IEnumerable zones, CancellationToken cancellationToken = default) + { + return context.StoreDeliveryZones.AddRangeAsync(zones, cancellationToken); + } + + /// + public Task AddHolidaysAsync(IEnumerable holidays, CancellationToken cancellationToken = default) + { + return context.StoreHolidays.AddRangeAsync(holidays, cancellationToken); + } + + /// + public Task AddTableAreasAsync(IEnumerable areas, CancellationToken cancellationToken = default) + { + return context.StoreTableAreas.AddRangeAsync(areas, cancellationToken); + } + + /// + public Task AddTablesAsync(IEnumerable tables, CancellationToken cancellationToken = default) + { + return context.StoreTables.AddRangeAsync(tables, cancellationToken); + } + + /// + public Task AddShiftsAsync(IEnumerable shifts, CancellationToken cancellationToken = default) + { + return context.StoreEmployeeShifts.AddRangeAsync(shifts, cancellationToken); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } + + /// + public Task UpdateStoreAsync(Store store, CancellationToken cancellationToken = default) + { + context.Stores.Update(store); + return Task.CompletedTask; + } + + /// + public async Task DeleteStoreAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.Stores + .Where(x => x.TenantId == tenantId && x.Id == storeId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing == null) + { + return; + } + + context.Stores.Remove(existing); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs new file mode 100644 index 0000000..4f50569 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs @@ -0,0 +1,102 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Infrastructure.Common.Options; +using TakeoutSaaS.Infrastructure.Common.Persistence; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Kernel.Ids; + +namespace TakeoutSaaS.Infrastructure.Common.Extensions; + +/// +/// 数据访问与多数据源相关的服务注册扩展。 +/// +public static class DatabaseServiceCollectionExtensions +{ + /// + /// 注册数据库基础设施(多数据源配置、读写分离、Dapper 执行器)。 + /// + /// 服务集合。 + /// 配置源。 + /// 服务集合。 + public static IServiceCollection AddDatabaseInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection(DatabaseOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddOptions() + .Bind(configuration.GetSection(IdGeneratorOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + return new SnowflakeIdGenerator(options.WorkerId, options.DatacenterId); + }); + + services.AddSingleton(); + services.AddScoped(); + return services; + } + + /// + /// 为指定 DbContext 注册读写分离的 PostgreSQL 配置,同时提供读上下文工厂。 + /// + /// 上下文类型。 + /// 服务集合。 + /// 逻辑数据源名称。 + /// 服务集合。 + public static IServiceCollection AddPostgresDbContext( + this IServiceCollection services, + string dataSourceName) + where TContext : DbContext + { + services.AddDbContext( + (sp, options) => + { + ConfigureDbContextOptions(sp, options, dataSourceName, DatabaseConnectionRole.Write); + }, + contextLifetime: ServiceLifetime.Scoped, + optionsLifetime: ServiceLifetime.Singleton); + + services.AddDbContextFactory((sp, options) => + { + ConfigureDbContextOptions(sp, options, dataSourceName, DatabaseConnectionRole.Read); + }); + + return services; + } + + /// + /// 配置 DbContextOptions,应用连接串、命令超时与重试策略。 + /// + /// 服务提供程序。 + /// 上下文配置器。 + /// 数据源名称。 + /// 连接角色。 + private static void ConfigureDbContextOptions( + IServiceProvider serviceProvider, + DbContextOptionsBuilder optionsBuilder, + string dataSourceName, + DatabaseConnectionRole role) + { + var connection = serviceProvider + .GetRequiredService() + .GetConnection(dataSourceName, role); + + optionsBuilder.UseNpgsql( + connection.ConnectionString, + npgsqlOptions => + { + npgsqlOptions.CommandTimeout(connection.CommandTimeoutSeconds); + npgsqlOptions.EnableRetryOnFailure( + connection.MaxRetryCount, + TimeSpan.FromSeconds(connection.MaxRetryDelaySeconds), + null); + }); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseDataSourceOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseDataSourceOptions.cs new file mode 100644 index 0000000..694c9e3 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseDataSourceOptions.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.Common.Options; + +/// +/// 单个数据源的连接配置,支持主写与多个从读。 +/// +public sealed class DatabaseDataSourceOptions +{ + /// + /// 主写连接串,读写分离缺省回退到此连接。 + /// + [Required] + public string? Write { get; set; } + + /// + /// 从读连接串集合,可为空。 + /// + public IList Reads { get; init; } = new List(); + + /// + /// 默认命令超时(秒),未设置时使用框架默认值。 + /// + [Range(1, 600)] + public int CommandTimeoutSeconds { get; set; } = 30; + + /// + /// 数据库重试次数。 + /// + [Range(0, 10)] + public int MaxRetryCount { get; set; } = 3; + + /// + /// 数据库重试最大延迟(秒)。 + /// + [Range(1, 60)] + public int MaxRetryDelaySeconds { get; set; } = 5; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseOptions.cs new file mode 100644 index 0000000..a2db8d2 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Options/DatabaseOptions.cs @@ -0,0 +1,33 @@ +namespace TakeoutSaaS.Infrastructure.Common.Options; + +/// +/// 数据源配置集合,键为逻辑数据源名称。 +/// +public sealed class DatabaseOptions +{ + /// + /// 配置节名称。 + /// + public const string SectionName = "Database"; + + /// + /// 数据源配置字典,键为数据源名称。 + /// + public IDictionary DataSources { get; init; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// 获取指定名称的数据源配置,不存在时返回 null。 + /// + /// 逻辑数据源名称。 + /// 数据源配置或 null。 + public DatabaseDataSourceOptions? Find(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return null; + } + + return DataSources.TryGetValue(name, out var options) ? options : null; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs new file mode 100644 index 0000000..4733274 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs @@ -0,0 +1,211 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TakeoutSaaS.Shared.Abstractions.Entities; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 应用基础 DbContext,统一处理审计字段、软删除与全局查询过滤。 +/// +public abstract class AppDbContext( + DbContextOptions options, + ICurrentUserAccessor? currentUserAccessor = null, + IIdGenerator? idGenerator = null) : DbContext(options) +{ + private readonly ICurrentUserAccessor? _currentUserAccessor = currentUserAccessor; + private readonly IIdGenerator? _idGenerator = idGenerator; + + /// + /// 构建模型时应用软删除过滤器。 + /// + /// 模型构建器。 + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + ApplySoftDeleteQueryFilters(modelBuilder); + modelBuilder.ApplyXmlComments(); + } + + /// + /// 保存更改前应用元数据填充。 + /// + /// 受影响行数。 + public override int SaveChanges() + { + OnBeforeSaving(); + return base.SaveChanges(); + } + + /// + /// 异步保存更改前应用元数据填充。 + /// + /// 取消标记。 + /// 受影响行数。 + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + OnBeforeSaving(); + return base.SaveChangesAsync(cancellationToken); + } + + /// + /// 保存前处理审计、软删除等元数据,可在子类中扩展。 + /// + protected virtual void OnBeforeSaving() + { + ApplyIdGeneration(); + ApplySoftDeleteMetadata(); + ApplyAuditMetadata(); + } + + /// + /// 为新增实体生成雪花 ID。 + /// + private void ApplyIdGeneration() + { + if (_idGenerator == null) + { + return; + } + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State != EntityState.Added) + { + continue; + } + + if (entry.Entity.Id == 0) + { + entry.Entity.Id = _idGenerator.NextId(); + } + } + } + + /// + /// 将软删除实体的删除操作转换为设置 DeletedAt。 + /// + private void ApplySoftDeleteMetadata() + { + var utcNow = DateTime.UtcNow; + var actor = GetCurrentUserIdOrNull(); + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added && entry.Entity.DeletedAt.HasValue) + { + entry.Entity.DeletedAt = null; + } + + if (entry.State != EntityState.Deleted) + { + continue; + } + + entry.State = EntityState.Modified; + entry.Entity.DeletedAt = utcNow; + if (entry.Entity is IAuditableEntity auditable) + { + auditable.DeletedBy = actor; + if (!auditable.UpdatedAt.HasValue) + { + auditable.UpdatedAt = utcNow; + auditable.UpdatedBy = actor; + } + } + } + } + + /// + /// 对审计实体填充创建与更新时间。 + /// + private void ApplyAuditMetadata() + { + var utcNow = DateTime.UtcNow; + var actor = GetCurrentUserIdOrNull(); + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedAt = utcNow; + entry.Entity.UpdatedAt = null; + entry.Entity.CreatedBy ??= actor; + entry.Entity.UpdatedBy = null; + entry.Entity.DeletedBy = null; + entry.Entity.DeletedAt = null; + } + else if (entry.State == EntityState.Modified) + { + entry.Entity.UpdatedAt = utcNow; + entry.Entity.UpdatedBy = actor; + } + } + } + + private long? GetCurrentUserIdOrNull() + { + var userId = _currentUserAccessor?.UserId ?? 0; + return userId == 0 ? null : userId; + } + + /// + /// 应用软删除查询过滤器,自动排除 DeletedAt 不为 null 的记录。 + /// + /// 模型构建器。 + protected void ApplySoftDeleteQueryFilters(ModelBuilder modelBuilder) + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (!typeof(ISoftDeleteEntity).IsAssignableFrom(entityType.ClrType)) + { + continue; + } + + var methodInfo = typeof(AppDbContext) + .GetMethod(nameof(SetSoftDeleteFilter), BindingFlags.Instance | BindingFlags.NonPublic)! + .MakeGenericMethod(entityType.ClrType); + + methodInfo.Invoke(this, new object[] { modelBuilder }); + } + } + + /// + /// 设置软删除查询过滤器。 + /// + /// 实体类型。 + /// 模型构建器。 + private void SetSoftDeleteFilter(ModelBuilder modelBuilder) + where TEntity : class, ISoftDeleteEntity + { + modelBuilder.Entity().HasQueryFilter(entity => entity.DeletedAt == null); + } + + /// + /// 配置审计字段的通用约束。 + /// + /// 实体类型。 + /// 实体构建器。 + protected static void ConfigureAuditableEntity(EntityTypeBuilder builder) + where TEntity : class, IAuditableEntity + { + builder.Property(x => x.CreatedAt).IsRequired(); + builder.Property(x => x.UpdatedAt); + builder.Property(x => x.DeletedAt); + builder.Property(x => x.CreatedBy); + builder.Property(x => x.UpdatedBy); + builder.Property(x => x.DeletedBy); + } + + /// + /// 配置软删除字段的通用约束。 + /// + /// 实体类型。 + /// 实体构建器。 + protected static void ConfigureSoftDeleteEntity(EntityTypeBuilder builder) + where TEntity : class, ISoftDeleteEntity + { + builder.Property(x => x.DeletedAt); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs new file mode 100644 index 0000000..e0761aa --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs @@ -0,0 +1,80 @@ +using System.Data; +using Microsoft.Extensions.Logging; +using Npgsql; +using TakeoutSaaS.Shared.Abstractions.Data; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 基于 Dapper 的执行器实现,封装连接创建与读写分离。 +/// +public sealed class DapperExecutor( + IDatabaseConnectionFactory connectionFactory, + ILogger logger) : IDapperExecutor +{ + /// + /// 使用指定数据源与读写角色执行异步查询。 + /// + public async Task QueryAsync( + string dataSourceName, + DatabaseConnectionRole role, + Func> query, + CancellationToken cancellationToken = default) + { + return await ExecuteAsync( + dataSourceName, + role, + async (connection, token) => await query(connection, token), + cancellationToken); + } + + /// + /// 使用指定数据源与读写角色执行异步命令。 + /// + public async Task ExecuteAsync( + string dataSourceName, + DatabaseConnectionRole role, + Func command, + CancellationToken cancellationToken = default) + { + await ExecuteAsync( + dataSourceName, + role, + async (connection, token) => + { + await command(connection, token); + return true; + }, + cancellationToken); + } + + /// + /// 获取默认命令超时时间(秒)。 + /// + public int GetDefaultCommandTimeoutSeconds(string dataSourceName, DatabaseConnectionRole role = DatabaseConnectionRole.Read) + { + var details = connectionFactory.GetConnection(dataSourceName, role); + return details.CommandTimeoutSeconds; + } + + /// + /// 核心执行逻辑:创建连接、打开并执行委托。 + /// + private async Task ExecuteAsync( + string dataSourceName, + DatabaseConnectionRole role, + Func> action, + CancellationToken cancellationToken) + { + var details = connectionFactory.GetConnection(dataSourceName, role); + await using var connection = new NpgsqlConnection(details.ConnectionString); + + logger.LogDebug( + "打开数据库连接:DataSource={DataSource} Role={Role}", + dataSourceName, + role); + + await connection.OpenAsync(cancellationToken); + return await action(connection, cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionDetails.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionDetails.cs new file mode 100644 index 0000000..20e4850 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionDetails.cs @@ -0,0 +1,10 @@ +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 数据库连接信息(连接串与超时/重试设置)。 +/// +public sealed record DatabaseConnectionDetails( + string ConnectionString, + int CommandTimeoutSeconds, + int MaxRetryCount, + int MaxRetryDelaySeconds); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs new file mode 100644 index 0000000..7852aaa --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs @@ -0,0 +1,121 @@ +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Security.Cryptography; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Infrastructure.Common.Options; +using TakeoutSaaS.Shared.Abstractions.Data; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 数据库连接工厂,支持读写分离及连接配置校验。 +/// +public sealed class DatabaseConnectionFactory( + IOptionsMonitor optionsMonitor, + IConfiguration configuration, + ILogger logger) : IDatabaseConnectionFactory +{ + private const int DefaultCommandTimeoutSeconds = 30; + private const int DefaultMaxRetryCount = 3; + private const int DefaultMaxRetryDelaySeconds = 5; + + /// + /// 获取指定数据源与读写角色的连接信息。 + /// + /// 逻辑数据源名称。 + /// 连接角色。 + /// 连接串与超时/重试配置。 + public DatabaseConnectionDetails GetConnection(string dataSourceName, DatabaseConnectionRole role) + { + if (string.IsNullOrWhiteSpace(dataSourceName)) + { + logger.LogWarning("请求的数据源名称为空,使用默认连接。"); + return BuildFallbackConnection(); + } + + var options = optionsMonitor.CurrentValue.Find(dataSourceName); + if (options != null) + { + if (!ValidateOptions(dataSourceName, options)) + { + return BuildFallbackConnection(); + } + + var connectionString = ResolveConnectionString(options, role); + return new DatabaseConnectionDetails( + connectionString, + options.CommandTimeoutSeconds, + options.MaxRetryCount, + options.MaxRetryDelaySeconds); + } + + var fallback = configuration.GetConnectionString(dataSourceName); + if (string.IsNullOrWhiteSpace(fallback)) + { + logger.LogError("缺少数据源 {DataSource} 的连接配置,回退到默认本地连接。", dataSourceName); + return BuildFallbackConnection(); + } + + logger.LogWarning("未找到数据源 {DataSource} 的 Database 节配置,回退使用 ConnectionStrings。", dataSourceName); + return new DatabaseConnectionDetails( + fallback, + DefaultCommandTimeoutSeconds, + DefaultMaxRetryCount, + DefaultMaxRetryDelaySeconds); + } + + /// + /// 校验数据源配置完整性。 + /// + /// 数据源名称。 + /// 数据源配置。 + /// 配置不合法时抛出。 + private bool ValidateOptions(string dataSourceName, DatabaseDataSourceOptions options) + { + var results = new List(); + var context = new ValidationContext(options); + if (!Validator.TryValidateObject(options, context, results, validateAllProperties: true)) + { + var errorMessages = string.Join("; ", results.Select(result => result.ErrorMessage)); + logger.LogError("数据源 {DataSource} 配置非法:{Errors},回退到默认连接。", dataSourceName, errorMessages); + return false; + } + + return true; + } + + /// + /// 根据读写角色选择连接串,从读连接随机分配。 + /// + /// 数据源配置。 + /// 连接角色。 + /// 可用连接串。 + private string ResolveConnectionString(DatabaseDataSourceOptions options, DatabaseConnectionRole role) + { + if (role == DatabaseConnectionRole.Read && options.Reads.Count > 0) + { + var index = RandomNumberGenerator.GetInt32(options.Reads.Count); + return options.Reads[index]; + } + + if (string.IsNullOrWhiteSpace(options.Write)) + { + return BuildFallbackConnection().ConnectionString; + } + + return options.Write; + } + + private DatabaseConnectionDetails BuildFallbackConnection() + { + const string fallback = "Host=120.53.222.17;Port=5432;Database=postgres;Username=postgres;Password=MsuMshk112233;Pooling=true;Minimum Pool Size=1;Maximum Pool Size=20"; + logger.LogWarning("使用默认回退连接串:{Connection}", fallback); + return new DatabaseConnectionDetails( + fallback, + DefaultCommandTimeoutSeconds, + DefaultMaxRetryCount, + DefaultMaxRetryDelaySeconds); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DesignTime/DesignTimeDbContextFactoryBase.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DesignTime/DesignTimeDbContextFactoryBase.cs new file mode 100644 index 0000000..5a2cf5f --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DesignTime/DesignTimeDbContextFactoryBase.cs @@ -0,0 +1,152 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; +using TakeoutSaaS.Infrastructure.Common.Options; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence.DesignTime; + +/// +/// EF Core 设计时 DbContext 工厂基类,统一读取 appsettings 中的数据库配置。 +/// +internal abstract class DesignTimeDbContextFactoryBase : IDesignTimeDbContextFactory + where TContext : TenantAwareDbContext +{ + private readonly string _dataSourceName; + private readonly string? _connectionStringEnvVar; + + protected DesignTimeDbContextFactoryBase(string dataSourceName, string? connectionStringEnvVar = null) + { + if (string.IsNullOrWhiteSpace(dataSourceName)) + { + throw new ArgumentException("数据源名称不能为空。", nameof(dataSourceName)); + } + + _dataSourceName = dataSourceName; + _connectionStringEnvVar = connectionStringEnvVar; + } + + public TContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql( + ResolveConnectionString(), + npgsql => + { + npgsql.CommandTimeout(30); + npgsql.EnableRetryOnFailure(); + }); + + return CreateContext( + optionsBuilder.Options, + new DesignTimeTenantProvider(), + new DesignTimeCurrentUserAccessor()); + } + + protected abstract TContext CreateContext( + DbContextOptions options, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor); + + private string ResolveConnectionString() + { + if (!string.IsNullOrWhiteSpace(_connectionStringEnvVar)) + { + var envValue = Environment.GetEnvironmentVariable(_connectionStringEnvVar); + if (!string.IsNullOrWhiteSpace(envValue)) + { + return envValue; + } + } + + var configuration = BuildConfiguration(); + var writeConnection = configuration[$"{DatabaseOptions.SectionName}:DataSources:{_dataSourceName}:Write"]; + if (string.IsNullOrWhiteSpace(writeConnection)) + { + throw new InvalidOperationException( + $"未在配置中找到数据源 '{_dataSourceName}' 的 Write 连接字符串,请检查 appsettings 或设置 {_connectionStringEnvVar ?? "相应"} 环境变量。"); + } + + return writeConnection; + } + + private static IConfigurationRoot BuildConfiguration() + { + var basePath = ResolveConfigurationDirectory(); + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + + return new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + } + + private static string ResolveConfigurationDirectory() + { + var explicitDir = Environment.GetEnvironmentVariable("TAKEOUTSAAS_APPSETTINGS_DIR"); + if (!string.IsNullOrWhiteSpace(explicitDir) && Directory.Exists(explicitDir)) + { + return explicitDir; + } + + var currentDir = Directory.GetCurrentDirectory(); + var solutionRoot = LocateSolutionRoot(currentDir); + + var candidateDirs = new[] + { + currentDir, + solutionRoot, + solutionRoot is null ? null : Path.Combine(solutionRoot, "src", "Api", "TakeoutSaaS.AdminApi"), + solutionRoot is null ? null : Path.Combine(solutionRoot, "src", "Api", "TakeoutSaaS.UserApi"), + solutionRoot is null ? null : Path.Combine(solutionRoot, "src", "Api", "TakeoutSaaS.MiniApi") + }.Where(dir => !string.IsNullOrWhiteSpace(dir)); + + foreach (var dir in candidateDirs) + { + if (dir != null && Directory.Exists(dir) && HasAppSettings(dir)) + { + return dir; + } + } + + throw new InvalidOperationException( + "未找到 appsettings 配置文件,请设置 TAKEOUTSAAS_APPSETTINGS_DIR 环境变量指向包含 appsettings*.json 的目录。"); + } + + private static string? LocateSolutionRoot(string currentPath) + { + var directoryInfo = new DirectoryInfo(currentPath); + while (directoryInfo != null) + { + if (File.Exists(Path.Combine(directoryInfo.FullName, "TakeoutSaaS.sln"))) + { + return directoryInfo.FullName; + } + + directoryInfo = directoryInfo.Parent; + } + + return null; + } + + private static bool HasAppSettings(string directory) => + File.Exists(Path.Combine(directory, "appsettings.json")) || + Directory.GetFiles(directory, "appsettings.*.json").Length > 0; + + private sealed class DesignTimeTenantProvider : ITenantProvider + { + public long GetCurrentTenantId() => 0; + } + + private sealed class DesignTimeCurrentUserAccessor : ICurrentUserAccessor + { + public long UserId => 0; + public bool IsAuthenticated => false; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/IDatabaseConnectionFactory.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/IDatabaseConnectionFactory.cs new file mode 100644 index 0000000..4a684df --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/IDatabaseConnectionFactory.cs @@ -0,0 +1,17 @@ +using TakeoutSaaS.Shared.Abstractions.Data; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 数据库连接工厂,负责按读写角色选择对应连接串及配置。 +/// +public interface IDatabaseConnectionFactory +{ + /// + /// 获取指定数据源与读写角色的连接信息。 + /// + /// 逻辑数据源名称。 + /// 连接角色(读/写)。 + /// 连接串与相关配置。 + DatabaseConnectionDetails GetConnection(string dataSourceName, DatabaseConnectionRole role); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs new file mode 100644 index 0000000..e143c8f --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs @@ -0,0 +1,136 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Xml.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// Applies XML documentation summaries to EF Core entities/columns as comments. +/// +internal static class ModelBuilderCommentExtensions +{ + public static void ApplyXmlComments(this ModelBuilder modelBuilder) + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + ApplyEntityComment(entityType); + } + } + + private static void ApplyEntityComment(IMutableEntityType entityType) + { + var clrType = entityType.ClrType; + if (clrType == null) + { + return; + } + + if (XmlDocCommentProvider.TryGetSummary(clrType, out var typeComment)) + { + entityType.SetComment(typeComment); + } + + foreach (var property in entityType.GetProperties()) + { + var propertyInfo = property.PropertyInfo; + if (propertyInfo == null) + { + continue; + } + + if (XmlDocCommentProvider.TryGetSummary(propertyInfo, out var propertyComment)) + { + property.SetComment(propertyComment); + } + } + } + + private static class XmlDocCommentProvider + { + private static readonly ConcurrentDictionary> Cache = new(); + + public static bool TryGetSummary(MemberInfo member, out string? summary) + { + summary = null; + var assembly = member switch + { + Type type => type.Assembly, + _ => member.DeclaringType?.Assembly + }; + + if (assembly == null) + { + return false; + } + + var map = Cache.GetOrAdd(assembly, LoadComments); + if (map.Count == 0) + { + return false; + } + + var key = GetMemberKey(member); + if (key == null || !map.TryGetValue(key, out var text)) + { + return false; + } + + summary = text; + return true; + } + + private static IReadOnlyDictionary LoadComments(Assembly assembly) + { + var dictionary = new Dictionary(StringComparer.Ordinal); + var xmlPath = Path.ChangeExtension(assembly.Location, ".xml"); + if (string.IsNullOrWhiteSpace(xmlPath) || !File.Exists(xmlPath)) + { + return dictionary; + } + + var document = XDocument.Load(xmlPath); + foreach (var member in document.Descendants("member")) + { + var name = member.Attribute("name")?.Value; + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + + var summary = member.Element("summary")?.Value; + if (string.IsNullOrWhiteSpace(summary)) + { + continue; + } + + var normalized = Normalize(summary); + if (!string.IsNullOrWhiteSpace(normalized)) + { + dictionary[name] = normalized; + } + } + + return dictionary; + } + + private static string? GetMemberKey(MemberInfo member) => + member switch + { + Type type => $"T:{GetFullName(type)}", + PropertyInfo property => $"P:{GetFullName(property.DeclaringType!)}.{property.Name}", + FieldInfo field => $"F:{GetFullName(field.DeclaringType!)}.{field.Name}", + _ => null + }; + + private static string GetFullName(Type type) => + (type.FullName ?? type.Name).Replace('+', '.'); + + private static string Normalize(string text) + { + var chars = text.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' '); + return string.Join(' ', chars.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs new file mode 100644 index 0000000..f743d24 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs @@ -0,0 +1,82 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Shared.Abstractions.Entities; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Infrastructure.Common.Persistence; + +/// +/// 多租户感知 DbContext:自动应用租户过滤并填充租户字段。 +/// +public abstract class TenantAwareDbContext( + DbContextOptions options, + ITenantProvider tenantProvider, + ICurrentUserAccessor? currentUserAccessor = null, + IIdGenerator? idGenerator = null) : AppDbContext(options, currentUserAccessor, idGenerator) +{ + private readonly ITenantProvider _tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider)); + + /// + /// 当前请求租户 ID。 + /// + protected long CurrentTenantId => _tenantProvider.GetCurrentTenantId(); + + /// + /// 保存前填充租户元数据并执行基础处理。 + /// + protected override void OnBeforeSaving() + { + ApplyTenantMetadata(); + base.OnBeforeSaving(); + } + + /// + /// 应用租户过滤器到所有实现 的实体。 + /// + /// 模型构建器。 + protected void ApplyTenantQueryFilters(ModelBuilder modelBuilder) + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (!typeof(IMultiTenantEntity).IsAssignableFrom(entityType.ClrType)) + { + continue; + } + + var methodInfo = typeof(TenantAwareDbContext) + .GetMethod(nameof(SetTenantFilter), BindingFlags.Instance | BindingFlags.NonPublic)! + .MakeGenericMethod(entityType.ClrType); + + methodInfo.Invoke(this, new object[] { modelBuilder }); + } + } + + /// + /// 为具体实体设置租户过滤器。 + /// + /// 实体类型。 + /// 模型构建器。 + private void SetTenantFilter(ModelBuilder modelBuilder) + where TEntity : class, IMultiTenantEntity + { + modelBuilder.Entity().HasQueryFilter(entity => entity.TenantId == CurrentTenantId); + } + + /// + /// 为新增实体填充租户 ID。 + /// + private void ApplyTenantMetadata() + { + var tenantId = CurrentTenantId; + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added && entry.Entity.TenantId == 0 && tenantId != 0) + { + entry.Entity.TenantId = tenantId; + } + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs new file mode 100644 index 0000000..41cc530 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Dictionary.Abstractions; +using TakeoutSaaS.Domain.Dictionary.Repositories; +using TakeoutSaaS.Infrastructure.Common.Extensions; +using TakeoutSaaS.Infrastructure.Common.Options; +using TakeoutSaaS.Infrastructure.Dictionary.Options; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; +using TakeoutSaaS.Infrastructure.Dictionary.Repositories; +using TakeoutSaaS.Infrastructure.Dictionary.Services; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Domain.SystemParameters.Repositories; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Extensions; + +/// +/// 字典基础设施注册扩展。 +/// +public static class DictionaryServiceCollectionExtensions +{ + /// + /// 注册字典模块基础设施。 + /// + /// 服务集合。 + /// 配置源。 + /// 服务集合。 + /// 缺少数据库配置时抛出。 + public static IServiceCollection AddDictionaryInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddDatabaseInfrastructure(configuration); + services.AddPostgresDbContext(DatabaseConstants.DictionaryDataSource); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddOptions() + .Bind(configuration.GetSection("Dictionary:Cache")) + .ValidateDataAnnotations(); + + return services; + } + + /// + /// 确保数据库连接已配置(Database 节或 ConnectionStrings)。 + /// + /// 配置源。 + /// 数据源名称。 + /// 未配置时抛出。 + private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName) + { + // 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。 + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheOptions.cs new file mode 100644 index 0000000..c5df7a0 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Options/DictionaryCacheOptions.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Infrastructure.Dictionary.Options; + +/// +/// 字典缓存配置。 +/// +public sealed class DictionaryCacheOptions +{ + /// + /// 缓存滑动过期时间。 + /// + public TimeSpan SlidingExpiration { get; set; } = TimeSpan.FromMinutes(30); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs new file mode 100644 index 0000000..53e7e24 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs @@ -0,0 +1,118 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.SystemParameters.Entities; +using TakeoutSaaS.Infrastructure.Common.Persistence; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +/// +/// 参数字典 DbContext:承载字典与系统参数。 +/// +public sealed class DictionaryDbContext( + DbContextOptions options, + ITenantProvider tenantProvider, + ICurrentUserAccessor? currentUserAccessor = null, + IIdGenerator? idGenerator = null) + : TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator) +{ + /// + /// 字典分组集合。 + /// + public DbSet DictionaryGroups => Set(); + + /// + /// 字典项集合。 + /// + public DbSet DictionaryItems => Set(); + + /// + /// 系统参数集合。 + /// + public DbSet SystemParameters => Set(); + + /// + /// 配置实体模型。 + /// + /// 模型构建器。 + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + ConfigureGroup(modelBuilder.Entity()); + ConfigureItem(modelBuilder.Entity()); + ConfigureSystemParameter(modelBuilder.Entity()); + ApplyTenantQueryFilters(modelBuilder); + } + + /// + /// 配置字典分组。 + /// + /// 实体构建器。 + private static void ConfigureGroup(EntityTypeBuilder builder) + { + builder.ToTable("dictionary_groups"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.Code).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Scope).HasConversion().IsRequired(); + builder.Property(x => x.Description).HasMaxLength(512); + builder.Property(x => x.IsEnabled).HasDefaultValue(true); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + } + + /// + /// 配置字典项。 + /// + /// 实体构建器。 + private static void ConfigureItem(EntityTypeBuilder builder) + { + builder.ToTable("dictionary_items"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.GroupId).IsRequired(); + builder.Property(x => x.Key).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Value).HasMaxLength(256).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(512); + builder.Property(x => x.SortOrder).HasDefaultValue(100); + builder.Property(x => x.IsEnabled).HasDefaultValue(true); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + + builder.HasOne(x => x.Group) + .WithMany(g => g.Items) + .HasForeignKey(x => x.GroupId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.GroupId, x.Key }).IsUnique(); + } + + /// + /// 配置系统参数。 + /// + /// 实体构建器。 + private static void ConfigureSystemParameter(EntityTypeBuilder builder) + { + builder.ToTable("system_parameters"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.Key).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Value).HasColumnType("text").IsRequired(); + builder.Property(x => x.Description).HasMaxLength(512); + builder.Property(x => x.SortOrder).HasDefaultValue(100); + builder.Property(x => x.IsEnabled).HasDefaultValue(true); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.Key }).IsUnique(); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDesignTimeDbContextFactory.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDesignTimeDbContextFactory.cs new file mode 100644 index 0000000..45d82e2 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDesignTimeDbContextFactory.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Infrastructure.Common.Persistence.DesignTime; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +/// +/// 设计时 DictionaryDbContext 工厂。 +/// +internal sealed class DictionaryDesignTimeDbContextFactory + : DesignTimeDbContextFactoryBase +{ + public DictionaryDesignTimeDbContextFactory() + : base(DatabaseConstants.DictionaryDataSource, "TAKEOUTSAAS_DICTIONARY_CONNECTION") + { + } + + protected override DictionaryDbContext CreateContext( + DbContextOptions options, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + => new(options, tenantProvider, currentUserAccessor); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs new file mode 100644 index 0000000..232ad67 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs @@ -0,0 +1,99 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Domain.Dictionary.Repositories; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories; + +/// +/// EF Core 字典仓储实现。 +/// +public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDictionaryRepository +{ + + public Task FindGroupByIdAsync(long id, CancellationToken cancellationToken = default) + => context.DictionaryGroups.FirstOrDefaultAsync(group => group.Id == id, cancellationToken); + + public Task FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default) + => context.DictionaryGroups.FirstOrDefaultAsync(group => group.Code == code, cancellationToken); + + public async Task> SearchGroupsAsync(DictionaryScope? scope, CancellationToken cancellationToken = default) + { + var query = context.DictionaryGroups.AsNoTracking(); + if (scope.HasValue) + { + query = query.Where(group => group.Scope == scope.Value); + } + + return await query + .OrderBy(group => group.Code) + .ToListAsync(cancellationToken); + } + + public Task AddGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default) + { + context.DictionaryGroups.Add(group); + return Task.CompletedTask; + } + + public Task RemoveGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default) + { + context.DictionaryGroups.Remove(group); + return Task.CompletedTask; + } + + public Task FindItemByIdAsync(long id, CancellationToken cancellationToken = default) + => context.DictionaryItems.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + + public async Task> GetItemsByGroupIdAsync(long groupId, CancellationToken cancellationToken = default) + { + return await context.DictionaryItems + .AsNoTracking() + .Where(item => item.GroupId == groupId) + .OrderBy(item => item.SortOrder) + .ToListAsync(cancellationToken); + } + + public Task AddItemAsync(DictionaryItem item, CancellationToken cancellationToken = default) + { + context.DictionaryItems.Add(item); + return Task.CompletedTask; + } + + public Task RemoveItemAsync(DictionaryItem item, CancellationToken cancellationToken = default) + { + context.DictionaryItems.Remove(item); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => context.SaveChangesAsync(cancellationToken); + + public async Task> GetItemsByCodesAsync(IEnumerable codes, long tenantId, bool includeSystem, CancellationToken cancellationToken = default) + { + var normalizedCodes = codes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim().ToLowerInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (normalizedCodes.Length == 0) + { + return Array.Empty(); + } + + var query = context.DictionaryItems + .AsNoTracking() + .IgnoreQueryFilters() + .Include(item => item.Group) + .Where(item => normalizedCodes.Contains(item.Group!.Code)); + + query = query.Where(item => item.TenantId == tenantId || (includeSystem && item.TenantId == 0)); + + return await query + .OrderBy(item => item.SortOrder) + .ToListAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfSystemParameterRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfSystemParameterRepository.cs new file mode 100644 index 0000000..1ec7868 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfSystemParameterRepository.cs @@ -0,0 +1,80 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.SystemParameters.Entities; +using TakeoutSaaS.Domain.SystemParameters.Repositories; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories; + +/// +/// 系统参数 EF Core 仓储实现。 +/// +public sealed class EfSystemParameterRepository(DictionaryDbContext context) : ISystemParameterRepository +{ + /// + public Task FindByIdAsync(long id, CancellationToken cancellationToken = default) + { + return context.SystemParameters + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + /// + public Task FindByKeyAsync(string key, CancellationToken cancellationToken = default) + { + var normalizedKey = key.Trim(); + return context.SystemParameters + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Key == normalizedKey, cancellationToken); + } + + /// + public async Task> SearchAsync(string? keyword, bool? isEnabled, CancellationToken cancellationToken = default) + { + var query = context.SystemParameters.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + query = query.Where(x => x.Key.Contains(normalized) || (x.Description != null && x.Description.Contains(normalized))); + } + + if (isEnabled.HasValue) + { + query = query.Where(x => x.IsEnabled == isEnabled.Value); + } + + var parameters = await query + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.Key) + .ToListAsync(cancellationToken); + + return parameters; + } + + /// + public Task AddAsync(SystemParameter parameter, CancellationToken cancellationToken = default) + { + return context.SystemParameters.AddAsync(parameter, cancellationToken).AsTask(); + } + + /// + public Task RemoveAsync(SystemParameter parameter, CancellationToken cancellationToken = default) + { + context.SystemParameters.Remove(parameter); + return Task.CompletedTask; + } + + /// + public Task UpdateAsync(SystemParameter parameter, CancellationToken cancellationToken = default) + { + context.SystemParameters.Update(parameter); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs new file mode 100644 index 0000000..372c467 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Application.Dictionary.Abstractions; +using TakeoutSaaS.Application.Dictionary.Models; +using TakeoutSaaS.Infrastructure.Dictionary.Options; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Services; + +/// +/// 基于 IDistributedCache 的字典缓存实现。 +/// +public sealed class DistributedDictionaryCache(IDistributedCache cache, IOptions options) : IDictionaryCache +{ + private readonly DictionaryCacheOptions _options = options.Value; + private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); + + public async Task?> GetAsync(long tenantId, string code, CancellationToken cancellationToken = default) + { + var cacheKey = BuildKey(tenantId, code); + var payload = await cache.GetAsync(cacheKey, cancellationToken); + if (payload == null || payload.Length == 0) + { + return null; + } + + return JsonSerializer.Deserialize>(payload, _serializerOptions); + } + + public Task SetAsync(long tenantId, string code, IReadOnlyList items, CancellationToken cancellationToken = default) + { + var cacheKey = BuildKey(tenantId, code); + var payload = JsonSerializer.SerializeToUtf8Bytes(items, _serializerOptions); + var options = new DistributedCacheEntryOptions + { + SlidingExpiration = _options.SlidingExpiration + }; + return cache.SetAsync(cacheKey, payload, options, cancellationToken); + } + + public Task RemoveAsync(long tenantId, string code, CancellationToken cancellationToken = default) + { + var cacheKey = BuildKey(tenantId, code); + return cache.RemoveAsync(cacheKey, cancellationToken); + } + + private static string BuildKey(long tenantId, string code) + => $"dictionary:{tenantId}:{code.ToLowerInvariant()}"; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs new file mode 100644 index 0000000..79c475c --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs @@ -0,0 +1,55 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using TakeoutSaaS.Infrastructure.Identity.Options; + +namespace TakeoutSaaS.Infrastructure.Identity.Extensions; + +/// +/// JWT 认证扩展 +/// +public static class JwtAuthenticationExtensions +{ + /// + /// 配置 JWT Bearer 认证 + /// + public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration) + { + var jwtOptions = configuration.GetSection("Identity:Jwt").Get() + ?? throw new InvalidOperationException("缺少 Identity:Jwt 配置"); + + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear(); + + services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.RequireHttpsMetadata = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtOptions.Issuer, + ValidateAudience = true, + ValidAudience = jwtOptions.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1), + NameClaimType = ClaimTypes.NameIdentifier, + RoleClaimType = ClaimTypes.Role + }; + }); + + return services; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0c8dc63 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,113 @@ +using System; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Infrastructure.Common.Extensions; +using TakeoutSaaS.Infrastructure.Common.Options; +using TakeoutSaaS.Infrastructure.Identity.Options; +using TakeoutSaaS.Infrastructure.Identity.Persistence; +using TakeoutSaaS.Infrastructure.Identity.Services; +using TakeoutSaaS.Shared.Abstractions.Constants; +using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser; + +namespace TakeoutSaaS.Infrastructure.Identity.Extensions; + +/// +/// 身份认证基础设施注入。 +/// +public static class ServiceCollectionExtensions +{ + /// + /// 注册身份认证基础设施(数据库、Redis、JWT、限流等)。 + /// + /// 服务集合。 + /// 配置源。 + /// 是否启用小程序相关依赖(如微信登录)。 + /// 是否启用后台账号初始化。 + /// 服务集合。 + /// 配置缺失时抛出。 + public static IServiceCollection AddIdentityInfrastructure( + this IServiceCollection services, + IConfiguration configuration, + bool enableMiniFeatures = false, + bool enableAdminSeed = false) + { + services.AddDatabaseInfrastructure(configuration); + services.AddPostgresDbContext(DatabaseConstants.IdentityDataSource); + + var redisConnection = configuration.GetConnectionString("Redis"); + if (string.IsNullOrWhiteSpace(redisConnection)) + { + throw new InvalidOperationException("缺少 Redis 连接字符串配置。"); + } + + services.AddStackExchangeRedisCache(options => + { + options.Configuration = redisConnection; + }); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped, PasswordHasher>(); + + services.AddOptions() + .Bind(configuration.GetSection("Identity:Jwt")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddOptions() + .Bind(configuration.GetSection("Identity:LoginRateLimit")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddOptions() + .Bind(configuration.GetSection("Identity:RefreshTokenStore")); + + if (enableMiniFeatures) + { + services.AddOptions() + .Bind(configuration.GetSection("Identity:WeChatMini")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddHttpClient(client => + { + client.BaseAddress = new Uri("https://api.weixin.qq.com/"); + client.Timeout = TimeSpan.FromSeconds(10); + }); + } + + if (enableAdminSeed) + { + services.AddOptions() + .Bind(configuration.GetSection("Identity:AdminSeed")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddHostedService(); + } + + return services; + } + + /// + /// 确保数据库连接已配置(Database 节或 ConnectionStrings)。 + /// + /// 配置源。 + /// 数据源名称。 + /// 未配置时抛出。 + private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName) + { + // 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。 + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs new file mode 100644 index 0000000..5a2f813 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.Identity.Options; + +/// +/// 管理后台初始账号配置。 +/// +public sealed class AdminSeedOptions +{ + /// + /// 初始用户列表。 + /// + public List Users { get; set; } = new(); +} + +/// +/// 种子用户配置:用于初始化管理后台账号。 +/// +public sealed class SeedUserOptions +{ + /// + /// 登录账号。 + /// + [Required] + public string Account { get; set; } = string.Empty; + + /// + /// 登录密码(明文,将在初始化时进行哈希处理)。 + /// + [Required] + public string Password { get; set; } = string.Empty; + + /// + /// 展示名称。 + /// + [Required] + public string DisplayName { get; set; } = string.Empty; + + /// + /// 所属租户 ID。 + /// + public long TenantId { get; set; } + + /// + /// 所属商户 ID(平台管理员为空)。 + /// + public long? MerchantId { get; set; } + + /// + /// 角色集合。 + /// + public string[] Roles { get; set; } = Array.Empty(); + + /// + /// 权限集合。 + /// + public string[] Permissions { get; set; } = Array.Empty(); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs new file mode 100644 index 0000000..18aed1d --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs @@ -0,0 +1,40 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.Identity.Options; + +/// +/// JWT 配置选项。 +/// +public sealed class JwtOptions +{ + /// + /// 令牌颁发者(Issuer)。 + /// + [Required] + public string Issuer { get; set; } = string.Empty; + + /// + /// 令牌受众(Audience)。 + /// + [Required] + public string Audience { get; set; } = string.Empty; + + /// + /// JWT 签名密钥(至少 32 个字符)。 + /// + [Required] + [MinLength(32)] + public string Secret { get; set; } = string.Empty; + + /// + /// 访问令牌过期时间(分钟),范围:5-1440。 + /// + [Range(5, 1440)] + public int AccessTokenExpirationMinutes { get; set; } = 60; + + /// + /// 刷新令牌过期时间(分钟),范围:60-20160(14天)。 + /// + [Range(60, 1440 * 14)] + public int RefreshTokenExpirationMinutes { get; set; } = 60 * 24 * 7; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs new file mode 100644 index 0000000..d9f7a42 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.Identity.Options; + +/// +/// 登录限流配置选项。 +/// +public sealed class LoginRateLimitOptions +{ + /// + /// 时间窗口(秒),范围:1-3600。 + /// + [Range(1, 3600)] + public int WindowSeconds { get; set; } = 60; + + /// + /// 时间窗口内允许的最大尝试次数,范围:1-100。 + /// + [Range(1, 100)] + public int MaxAttempts { get; set; } = 5; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs new file mode 100644 index 0000000..fcbf6e8 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Infrastructure.Identity.Options; + +/// +/// 刷新令牌存储配置选项。 +/// +public sealed class RefreshTokenStoreOptions +{ + /// + /// Redis 键前缀,用于存储刷新令牌。 + /// + public string Prefix { get; set; } = "identity:refresh:"; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs new file mode 100644 index 0000000..0dee3be --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Infrastructure.Identity.Options; + +/// +/// 微信小程序配置选项。 +/// +public sealed class WeChatMiniOptions +{ + /// + /// 微信小程序 AppId。 + /// + [Required] + public string AppId { get; set; } = string.Empty; + + /// + /// 微信小程序 AppSecret。 + /// + [Required] + public string Secret { get; set; } = string.Empty; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs new file mode 100644 index 0000000..1f2bc95 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; + +namespace TakeoutSaaS.Infrastructure.Identity.Persistence; + +/// +/// EF Core 后台用户仓储实现。 +/// +public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIdentityUserRepository +{ + + public Task FindByAccountAsync(string account, CancellationToken cancellationToken = default) + => dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Account == account, cancellationToken); + + public Task FindByIdAsync(long userId, CancellationToken cancellationToken = default) + => dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + + public async Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default) + { + var query = dbContext.IdentityUsers + .AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + query = query.Where(x => x.Account.Contains(normalized) || x.DisplayName.Contains(normalized)); + } + + return await query.ToListAsync(cancellationToken); + } + + public Task> GetByIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default) + => dbContext.IdentityUsers.AsNoTracking() + .Where(x => x.TenantId == tenantId && userIds.Contains(x.Id)) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs new file mode 100644 index 0000000..3276793 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; + +namespace TakeoutSaaS.Infrastructure.Identity.Persistence; + +/// +/// EF Core 小程序用户仓储实现。 +/// +public sealed class EfMiniUserRepository(IdentityDbContext dbContext) : IMiniUserRepository +{ + + public Task FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default) + => dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken); + + public Task FindByIdAsync(long id, CancellationToken cancellationToken = default) + => dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + public async Task CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, long tenantId, CancellationToken cancellationToken = default) + { + var user = await dbContext.MiniUsers.FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken); + if (user == null) + { + user = new MiniUser + { + Id = 0, + OpenId = openId, + UnionId = unionId, + Nickname = nickname ?? "小程序用户", + Avatar = avatar, + TenantId = tenantId + }; + dbContext.MiniUsers.Add(user); + } + else + { + user.UnionId = unionId ?? user.UnionId; + user.Nickname = nickname ?? user.Nickname; + user.Avatar = avatar ?? user.Avatar; + } + + await dbContext.SaveChangesAsync(cancellationToken); + return user; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs new file mode 100644 index 0000000..c07f15d --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; + +namespace TakeoutSaaS.Infrastructure.Identity.Persistence; + +/// +/// EF 权限仓储。 +/// +public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermissionRepository +{ + public Task FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default) + => dbContext.Permissions.AsNoTracking().FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId, cancellationToken); + + public Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default) + => dbContext.Permissions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId, cancellationToken); + + public Task> GetByIdsAsync(long tenantId, IEnumerable permissionIds, CancellationToken cancellationToken = default) + => dbContext.Permissions.AsNoTracking() + .Where(x => x.TenantId == tenantId && permissionIds.Contains(x.Id)) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + + public Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default) + { + var query = dbContext.Permissions.AsNoTracking().Where(x => x.TenantId == tenantId); + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + query = query.Where(x => x.Name.Contains(normalized) || x.Code.Contains(normalized)); + } + + return query.ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public Task AddAsync(Permission permission, CancellationToken cancellationToken = default) + { + dbContext.Permissions.Add(permission); + return Task.CompletedTask; + } + + public Task UpdateAsync(Permission permission, CancellationToken cancellationToken = default) + { + dbContext.Permissions.Update(permission); + return Task.CompletedTask; + } + + public async Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default) + { + var entity = await dbContext.Permissions.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId, cancellationToken); + if (entity != null) + { + dbContext.Permissions.Remove(entity); + } + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => dbContext.SaveChangesAsync(cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs new file mode 100644 index 0000000..0409fff --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; + +namespace TakeoutSaaS.Infrastructure.Identity.Persistence; + +/// +/// EF 角色-权限仓储。 +/// +public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IRolePermissionRepository +{ + public Task> GetByRoleIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default) + => dbContext.RolePermissions.AsNoTracking() + .Where(x => x.TenantId == tenantId && roleIds.Contains(x.RoleId)) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + + public async Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable permissionIds, CancellationToken cancellationToken = default) + { + var existing = await dbContext.RolePermissions + .Where(x => x.TenantId == tenantId && x.RoleId == roleId) + .ToListAsync(cancellationToken); + + dbContext.RolePermissions.RemoveRange(existing); + + var toAdd = permissionIds.Distinct().Select(permissionId => new RolePermission + { + TenantId = tenantId, + RoleId = roleId, + PermissionId = permissionId + }); + + await dbContext.RolePermissions.AddRangeAsync(toAdd, cancellationToken); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => dbContext.SaveChangesAsync(cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleRepository.cs new file mode 100644 index 0000000..a6c43c4 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleRepository.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; + +namespace TakeoutSaaS.Infrastructure.Identity.Persistence; + +/// +/// EF 角色仓储。 +/// +public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleRepository +{ + public Task FindByIdAsync(long roleId, long tenantId, CancellationToken cancellationToken = default) + => dbContext.Roles.AsNoTracking().FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId, cancellationToken); + + public Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default) + => dbContext.Roles.AsNoTracking().FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId, cancellationToken); + + public Task> GetByIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default) + => dbContext.Roles.AsNoTracking() + .Where(x => x.TenantId == tenantId && roleIds.Contains(x.Id)) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + + public Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default) + { + var query = dbContext.Roles.AsNoTracking().Where(x => x.TenantId == tenantId); + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + query = query.Where(x => x.Name.Contains(normalized) || x.Code.Contains(normalized)); + } + + return query.ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public Task AddAsync(Role role, CancellationToken cancellationToken = default) + { + dbContext.Roles.Add(role); + return Task.CompletedTask; + } + + public Task UpdateAsync(Role role, CancellationToken cancellationToken = default) + { + dbContext.Roles.Update(role); + return Task.CompletedTask; + } + + public async Task DeleteAsync(long roleId, long tenantId, CancellationToken cancellationToken = default) + { + var entity = await dbContext.Roles.FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId, cancellationToken); + if (entity != null) + { + dbContext.Roles.Remove(entity); + } + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => dbContext.SaveChangesAsync(cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfUserRoleRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfUserRoleRepository.cs new file mode 100644 index 0000000..bf3c334 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfUserRoleRepository.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; + +namespace TakeoutSaaS.Infrastructure.Identity.Persistence; + +/// +/// EF 用户-角色仓储。 +/// +public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRoleRepository +{ + public Task> GetByUserIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default) + => dbContext.UserRoles.AsNoTracking() + .Where(x => x.TenantId == tenantId && userIds.Contains(x.UserId)) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + + public Task> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default) + => dbContext.UserRoles.AsNoTracking() + .Where(x => x.TenantId == tenantId && x.UserId == userId) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + + public async Task ReplaceUserRolesAsync(long tenantId, long userId, IEnumerable roleIds, CancellationToken cancellationToken = default) + { + var existing = await dbContext.UserRoles + .Where(x => x.TenantId == tenantId && x.UserId == userId) + .ToListAsync(cancellationToken); + + dbContext.UserRoles.RemoveRange(existing); + + var toAdd = roleIds.Distinct().Select(roleId => new UserRole + { + TenantId = tenantId, + UserId = userId, + RoleId = roleId + }); + + await dbContext.UserRoles.AddRangeAsync(toAdd, cancellationToken); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => dbContext.SaveChangesAsync(cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs new file mode 100644 index 0000000..a9246dd --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs @@ -0,0 +1,182 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Infrastructure.Identity.Options; +using TakeoutSaaS.Shared.Abstractions.Tenancy; +using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser; +using DomainPermission = TakeoutSaaS.Domain.Identity.Entities.Permission; +using DomainRole = TakeoutSaaS.Domain.Identity.Entities.Role; +using DomainRolePermission = TakeoutSaaS.Domain.Identity.Entities.RolePermission; +using DomainUserRole = TakeoutSaaS.Domain.Identity.Entities.UserRole; + +namespace TakeoutSaaS.Infrastructure.Identity.Persistence; + +/// +/// 后台账号初始化种子任务 +/// +public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger logger) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + var options = scope.ServiceProvider.GetRequiredService>().Value; + var passwordHasher = scope.ServiceProvider.GetRequiredService>(); + var tenantContextAccessor = scope.ServiceProvider.GetRequiredService(); + + await context.Database.MigrateAsync(cancellationToken); + + if (options.Users is null or { Count: 0 }) + { + logger.LogInformation("AdminSeed 未配置账号,跳过后台账号初始化"); + return; + } + + foreach (var userOptions in options.Users) + { + using var tenantScope = EnterTenantScope(tenantContextAccessor, userOptions.TenantId); + var user = await context.IdentityUsers.FirstOrDefaultAsync(x => x.Account == userOptions.Account, cancellationToken); + var roles = NormalizeValues(userOptions.Roles); + var permissions = NormalizeValues(userOptions.Permissions); + + if (user == null) + { + user = new DomainIdentityUser + { + Id = 0, + Account = userOptions.Account, + DisplayName = userOptions.DisplayName, + TenantId = userOptions.TenantId, + MerchantId = userOptions.MerchantId, + Avatar = null + }; + user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password); + context.IdentityUsers.Add(user); + logger.LogInformation("已创建后台账号 {Account}", user.Account); + } + else + { + user.DisplayName = userOptions.DisplayName; + user.TenantId = userOptions.TenantId; + user.MerchantId = userOptions.MerchantId; + user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password); + logger.LogInformation("已更新后台账号 {Account}", user.Account); + } + + // 确保角色存在 + var existingRoles = await context.Roles + .Where(r => r.TenantId == userOptions.TenantId && roles.Contains(r.Code)) + .ToListAsync(cancellationToken); + var existingRoleCodes = existingRoles.Select(r => r.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var code in roles) + { + if (existingRoleCodes.Contains(code)) + { + continue; + } + + context.Roles.Add(new DomainRole + { + TenantId = userOptions.TenantId, + Code = code, + Name = code, + Description = $"Seed role {code}" + }); + } + + // 确保权限存在 + var existingPermissions = await context.Permissions + .Where(p => p.TenantId == userOptions.TenantId && permissions.Contains(p.Code)) + .ToListAsync(cancellationToken); + var existingPermissionCodes = existingPermissions.Select(p => p.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var code in permissions) + { + if (existingPermissionCodes.Contains(code)) + { + continue; + } + + context.Permissions.Add(new DomainPermission + { + TenantId = userOptions.TenantId, + Code = code, + Name = code, + Description = $"Seed permission {code}" + }); + } + + await context.SaveChangesAsync(cancellationToken); + + // 重新加载角色/权限以获取 Id + var roleEntities = await context.Roles + .Where(r => r.TenantId == userOptions.TenantId && roles.Contains(r.Code)) + .ToListAsync(cancellationToken); + var permissionEntities = await context.Permissions + .Where(p => p.TenantId == userOptions.TenantId && permissions.Contains(p.Code)) + .ToListAsync(cancellationToken); + + // 重置用户角色 + var existingUserRoles = await context.UserRoles + .Where(ur => ur.TenantId == userOptions.TenantId && ur.UserId == user.Id) + .ToListAsync(cancellationToken); + context.UserRoles.RemoveRange(existingUserRoles); + + var roleIds = roleEntities.Select(r => r.Id).Distinct().ToArray(); + var userRoles = roleIds.Select(roleId => new DomainUserRole + { + TenantId = userOptions.TenantId, + UserId = user.Id, + RoleId = roleId + }); + await context.UserRoles.AddRangeAsync(userRoles, cancellationToken); + + // 为种子角色绑定种子权限 + if (permissions.Length > 0 && roleIds.Length > 0) + { + var permissionIds = permissionEntities.Select(p => p.Id).Distinct().ToArray(); + var existingRolePermissions = await context.RolePermissions + .Where(rp => rp.TenantId == userOptions.TenantId && roleIds.Contains(rp.RoleId)) + .ToListAsync(cancellationToken); + context.RolePermissions.RemoveRange(existingRolePermissions); + + var newRolePermissions = roleIds.SelectMany(roleId => permissionIds.Select(permissionId => new DomainRolePermission + { + TenantId = userOptions.TenantId, + RoleId = roleId, + PermissionId = permissionId + })); + await context.RolePermissions.AddRangeAsync(newRolePermissions, cancellationToken); + } + } + + await context.SaveChangesAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private static string[] NormalizeValues(string[]? values) + => values == null + ? [] + : [.. values + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase)]; + + private static IDisposable EnterTenantScope(ITenantContextAccessor accessor, long tenantId) + { + var previous = accessor.Current; + accessor.Current = new TenantContext(tenantId, null, "admin-seed"); + return new Scope(() => accessor.Current = previous); + } + + private sealed class Scope(Action disposeAction) : IDisposable + { + private readonly Action _disposeAction = disposeAction; + public void Dispose() => _disposeAction(); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs new file mode 100644 index 0000000..dfe4d4f --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -0,0 +1,161 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Infrastructure.Common.Persistence; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Infrastructure.Identity.Persistence; + +/// +/// 身份认证 DbContext,带多租户过滤与审计字段处理。 +/// +public sealed class IdentityDbContext( + DbContextOptions options, + ITenantProvider tenantProvider, + ICurrentUserAccessor? currentUserAccessor = null, + IIdGenerator? idGenerator = null) + : TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator) +{ + /// + /// 管理后台用户集合。 + /// + public DbSet IdentityUsers => Set(); + + /// + /// 小程序用户集合。 + /// + public DbSet MiniUsers => Set(); + + /// + /// 角色集合。 + /// + public DbSet Roles => Set(); + + /// + /// 权限集合。 + /// + public DbSet Permissions => Set(); + + /// + /// 用户-角色关系。 + /// + public DbSet UserRoles => Set(); + + /// + /// 角色-权限关系。 + /// + public DbSet RolePermissions => Set(); + + /// + /// 配置实体模型。 + /// + /// 模型构建器。 + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + ConfigureIdentityUser(modelBuilder.Entity()); + ConfigureMiniUser(modelBuilder.Entity()); + ConfigureRole(modelBuilder.Entity()); + ConfigurePermission(modelBuilder.Entity()); + ConfigureUserRole(modelBuilder.Entity()); + ConfigureRolePermission(modelBuilder.Entity()); + ApplyTenantQueryFilters(modelBuilder); + } + + /// + /// 配置管理后台用户实体。 + /// + /// 实体构建器。 + private static void ConfigureIdentityUser(EntityTypeBuilder builder) + { + builder.ToTable("identity_users"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Account).HasMaxLength(64).IsRequired(); + builder.Property(x => x.DisplayName).HasMaxLength(64).IsRequired(); + builder.Property(x => x.PasswordHash).HasMaxLength(256).IsRequired(); + builder.Property(x => x.Avatar).HasMaxLength(256); + builder.Property(x => x.TenantId).IsRequired(); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.Account }).IsUnique(); + } + + /// + /// 配置小程序用户实体。 + /// + /// 实体构建器。 + private static void ConfigureMiniUser(EntityTypeBuilder builder) + { + builder.ToTable("mini_users"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.OpenId).HasMaxLength(128).IsRequired(); + builder.Property(x => x.UnionId).HasMaxLength(128); + builder.Property(x => x.Nickname).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Avatar).HasMaxLength(256); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.OpenId }).IsUnique(); + } + + private static void ConfigureRole(EntityTypeBuilder builder) + { + builder.ToTable("roles"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Code).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(256); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + } + + private static void ConfigurePermission(EntityTypeBuilder builder) + { + builder.ToTable("permissions"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Code).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(256); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); + } + + private static void ConfigureUserRole(EntityTypeBuilder builder) + { + builder.ToTable("user_roles"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.UserId).IsRequired(); + builder.Property(x => x.RoleId).IsRequired(); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId }).IsUnique(); + } + + private static void ConfigureRolePermission(EntityTypeBuilder builder) + { + builder.ToTable("role_permissions"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.RoleId).IsRequired(); + builder.Property(x => x.PermissionId).IsRequired(); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.RoleId, x.PermissionId }).IsUnique(); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDesignTimeDbContextFactory.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDesignTimeDbContextFactory.cs new file mode 100644 index 0000000..c18d179 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDesignTimeDbContextFactory.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Infrastructure.Common.Persistence.DesignTime; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Infrastructure.Identity.Persistence; + +/// +/// 设计时 IdentityDbContext 工厂,供 EF Core CLI 生成迁移使用。 +/// +internal sealed class IdentityDesignTimeDbContextFactory + : DesignTimeDbContextFactoryBase +{ + public IdentityDesignTimeDbContextFactory() + : base(DatabaseConstants.IdentityDataSource, "TAKEOUTSAAS_IDENTITY_CONNECTION") + { + } + + protected override IdentityDbContext CreateContext( + DbContextOptions options, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + => new(options, tenantProvider, currentUserAccessor); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs new file mode 100644 index 0000000..78d3f0e --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs @@ -0,0 +1,95 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Infrastructure.Identity.Options; + +namespace TakeoutSaaS.Infrastructure.Identity.Services; + +/// +/// JWT 令牌生成器。 +/// +public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions options) : IJwtTokenService +{ + private readonly JwtSecurityTokenHandler _tokenHandler = new(); + private readonly JwtOptions _options = options.Value; + + /// + /// 创建访问令牌和刷新令牌对。 + /// + /// 用户档案 + /// 是否为新用户(首次登录) + /// 取消令牌 + /// 令牌响应 + public async Task CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var accessExpires = now.AddMinutes(_options.AccessTokenExpirationMinutes); + var refreshExpires = now.AddMinutes(_options.RefreshTokenExpirationMinutes); + + // 1. 构建 JWT Claims(包含用户 ID、账号、租户 ID、商户 ID、角色、权限等) + var claims = BuildClaims(profile); + + // 2. 创建签名凭据(使用 HMAC SHA256 算法) + var signingCredentials = new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)), + SecurityAlgorithms.HmacSha256); + + // 3. 创建 JWT 安全令牌 + var jwt = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: claims, + notBefore: now, + expires: accessExpires, + signingCredentials: signingCredentials); + + // 4. 序列化 JWT 为字符串 + var accessToken = _tokenHandler.WriteToken(jwt); + + // 5. 生成刷新令牌并存储到 Redis + var refreshDescriptor = await refreshTokenStore.IssueAsync(profile.UserId, refreshExpires, cancellationToken); + + return new TokenResponse + { + AccessToken = accessToken, + AccessTokenExpiresAt = accessExpires, + RefreshToken = refreshDescriptor.Token, + RefreshTokenExpiresAt = refreshDescriptor.ExpiresAt, + User = profile, + IsNewUser = isNewUser + }; + } + + /// + /// 构建 JWT Claims:将用户档案转换为 Claims 集合。 + /// + /// 用户档案 + /// Claims 集合 + private static List BuildClaims(CurrentUserProfile profile) + { + var userId = profile.UserId.ToString(); + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, userId), + new(ClaimTypes.NameIdentifier, userId), + new(JwtRegisteredClaimNames.UniqueName, profile.Account), + new("tenant_id", profile.TenantId.ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + + if (profile.MerchantId.HasValue) + { + claims.Add(new Claim("merchant_id", profile.MerchantId.Value.ToString())); + } + + claims.AddRange(profile.Roles.Select(role => new Claim(ClaimTypes.Role, role))); + + claims.AddRange(profile.Permissions.Select(permission => new Claim("permission", permission))); + + return claims; + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs new file mode 100644 index 0000000..e997c2e --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Infrastructure.Identity.Options; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Infrastructure.Identity.Services; + +/// +/// Redis 登录限流实现。 +/// +public sealed class RedisLoginRateLimiter(IDistributedCache cache, IOptions options) : ILoginRateLimiter +{ + private readonly LoginRateLimitOptions _options = options.Value; + + public async Task EnsureAllowedAsync(string key, CancellationToken cancellationToken = default) + { + var cacheKey = BuildKey(key); + var current = await cache.GetStringAsync(cacheKey, cancellationToken); + var count = string.IsNullOrWhiteSpace(current) ? 0 : int.Parse(current); + if (count >= _options.MaxAttempts) + { + throw new BusinessException(ErrorCodes.Forbidden, "尝试次数过多,请稍后再试"); + } + + count++; + await cache.SetStringAsync( + cacheKey, + count.ToString(), + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_options.WindowSeconds) + }, + cancellationToken); + } + + public Task ResetAsync(string key, CancellationToken cancellationToken = default) + => cache.RemoveAsync(BuildKey(key), cancellationToken); + + private static string BuildKey(string key) => $"identity:login:{key}"; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs new file mode 100644 index 0000000..36105e9 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs @@ -0,0 +1,56 @@ +using System; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Application.Identity.Models; +using TakeoutSaaS.Infrastructure.Identity.Options; + +namespace TakeoutSaaS.Infrastructure.Identity.Services; + +/// +/// Redis 刷新令牌存储。 +/// +public sealed class RedisRefreshTokenStore(IDistributedCache cache, IOptions options) : IRefreshTokenStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly RefreshTokenStoreOptions _options = options.Value; + + public async Task IssueAsync(long userId, DateTime expiresAt, CancellationToken cancellationToken = default) + { + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48)); + var descriptor = new RefreshTokenDescriptor(token, userId, expiresAt, false); + + var key = BuildKey(token); + var entryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = expiresAt }; + await cache.SetStringAsync(key, JsonSerializer.Serialize(descriptor, JsonOptions), entryOptions, cancellationToken); + + return descriptor; + } + + public async Task GetAsync(string refreshToken, CancellationToken cancellationToken = default) + { + var json = await cache.GetStringAsync(BuildKey(refreshToken), cancellationToken); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, JsonOptions); + } + + public async Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default) + { + var descriptor = await GetAsync(refreshToken, cancellationToken); + if (descriptor == null) + { + return; + } + + var updated = descriptor with { Revoked = true }; + var entryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = updated.ExpiresAt }; + await cache.SetStringAsync(BuildKey(refreshToken), JsonSerializer.Serialize(updated, JsonOptions), entryOptions, cancellationToken); + } + + private string BuildKey(string token) => $"{_options.Prefix}{token}"; +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs new file mode 100644 index 0000000..1bd8b1b --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs @@ -0,0 +1,72 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Application.Identity.Abstractions; +using TakeoutSaaS.Infrastructure.Identity.Options; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Infrastructure.Identity.Services; + +/// +/// 微信 code2Session 实现 +/// +public sealed class WeChatAuthService(HttpClient httpClient, IOptions options) : IWeChatAuthService +{ + private readonly WeChatMiniOptions _options = options.Value; + + public async Task Code2SessionAsync(string code, CancellationToken cancellationToken = default) + { + var requestUri = $"sns/jscode2session?appid={Uri.EscapeDataString(_options.AppId)}&secret={Uri.EscapeDataString(_options.Secret)}&js_code={Uri.EscapeDataString(code)}&grant_type=authorization_code"; + using var response = await httpClient.GetAsync(requestUri, cancellationToken); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (payload == null) + { + throw new BusinessException(ErrorCodes.Unauthorized, "微信登录失败:响应为空"); + } + + if (payload.ErrorCode.HasValue && payload.ErrorCode.Value != 0) + { + var message = string.IsNullOrWhiteSpace(payload.ErrorMessage) + ? $"微信登录失败,错误码:{payload.ErrorCode}" + : payload.ErrorMessage; + throw new BusinessException(ErrorCodes.Unauthorized, message); + } + + if (string.IsNullOrWhiteSpace(payload.OpenId) || string.IsNullOrWhiteSpace(payload.SessionKey)) + { + throw new BusinessException(ErrorCodes.Unauthorized, "微信登录失败:返回数据无效"); + } + + return new WeChatSessionInfo + { + OpenId = payload.OpenId, + UnionId = payload.UnionId, + SessionKey = payload.SessionKey + }; + } + + private sealed class WeChatSessionResponse + { + [JsonPropertyName("openid")] + public string? OpenId { get; set; } + + [JsonPropertyName("unionid")] + public string? UnionId { get; set; } + + [JsonPropertyName("session_key")] + public string? SessionKey { get; set; } + + [JsonPropertyName("errcode")] + public int? ErrorCode { get; set; } + + [JsonPropertyName("errmsg")] + public string? ErrorMessage { get; set; } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.Designer.cs new file mode 100644 index 0000000..84faa52 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.Designer.cs @@ -0,0 +1,5799 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.App.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + [DbContext(typeof(TakeoutAppDbContext))] + [Migration("20251202005208_InitSnowflake_App")] + partial class InitSnowflake_App + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricAlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConditionJson") + .IsRequired() + .HasColumnType("text") + .HasComment("触发条件 JSON。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasComment("是否启用。"); + + b.Property("MetricDefinitionId") + .HasColumnType("bigint") + .HasComment("关联指标。"); + + b.Property("NotificationChannels") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("通知渠道。"); + + b.Property("Severity") + .HasColumnType("integer") + .HasComment("告警级别。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MetricDefinitionId", "Severity"); + + b.ToTable("metric_alert_rules", null, t => + { + t.HasComment("指标告警规则。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("指标编码。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DefaultAggregation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("默认聚合方式。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("说明。"); + + b.Property("DimensionsJson") + .HasColumnType("text") + .HasComment("维度描述 JSON。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("指标名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("metric_definitions", null, t => + { + t.HasComment("指标定义,描述可观测的数据点。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DimensionKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("维度键(JSON)。"); + + b.Property("MetricDefinitionId") + .HasColumnType("bigint") + .HasComment("指标定义 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasComment("数值。"); + + b.Property("WindowEnd") + .HasColumnType("timestamp with time zone") + .HasComment("统计时间窗口结束。"); + + b.Property("WindowStart") + .HasColumnType("timestamp with time zone") + .HasComment("统计时间窗口开始。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MetricDefinitionId", "DimensionKey", "WindowStart", "WindowEnd") + .IsUnique(); + + b.ToTable("metric_snapshots", null, t => + { + t.HasComment("指标快照,用于大盘展示。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.Coupon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("券码或序列号。"); + + b.Property("CouponTemplateId") + .HasColumnType("bigint") + .HasComment("模板标识。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone") + .HasComment("到期时间。"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasComment("发放时间。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("订单 ID(已使用时记录)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasComment("使用时间。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("归属用户。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("coupons", null, t => + { + t.HasComment("用户领取的券。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.CouponTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowStack") + .HasColumnType("boolean") + .HasComment("是否允许叠加其他优惠。"); + + b.Property("ChannelsJson") + .HasColumnType("text") + .HasComment("发放渠道(JSON)。"); + + b.Property("ClaimedQuantity") + .HasColumnType("integer") + .HasComment("已领取数量。"); + + b.Property("CouponType") + .HasColumnType("integer") + .HasComment("券类型。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注。"); + + b.Property("DiscountCap") + .HasColumnType("numeric") + .HasComment("折扣上限(针对折扣券)。"); + + b.Property("MinimumSpend") + .HasColumnType("numeric") + .HasComment("最低消费门槛。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("模板名称。"); + + b.Property("ProductScopeJson") + .HasColumnType("text") + .HasComment("适用品类或商品范围(JSON)。"); + + b.Property("RelativeValidDays") + .HasColumnType("integer") + .HasComment("有效天数(相对发放时间)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("StoreScopeJson") + .HasColumnType("text") + .HasComment("适用门店 ID 集合(JSON)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TotalQuantity") + .HasColumnType("integer") + .HasComment("总发放数量上限。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone") + .HasComment("可用开始时间。"); + + b.Property("ValidTo") + .HasColumnType("timestamp with time zone") + .HasComment("可用结束时间。"); + + b.Property("Value") + .HasColumnType("numeric") + .HasComment("面值或折扣额度。"); + + b.HasKey("Id"); + + b.ToTable("coupon_templates", null, t => + { + t.HasComment("优惠券模板。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PromotionCampaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AudienceDescription") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("目标人群描述。"); + + b.Property("BannerUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("营销素材(如 banner)。"); + + b.Property("Budget") + .HasColumnType("numeric") + .HasComment("预算金额。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndAt") + .HasColumnType("timestamp with time zone") + .HasComment("结束时间。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("活动名称。"); + + b.Property("PromotionType") + .HasColumnType("integer") + .HasComment("活动类型。"); + + b.Property("RulesJson") + .IsRequired() + .HasColumnType("text") + .HasComment("活动规则 JSON。"); + + b.Property("StartAt") + .HasColumnType("timestamp with time zone") + .HasComment("开始时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("活动状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.ToTable("promotion_campaigns", null, t => + { + t.HasComment("营销活动配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChatSessionId") + .HasColumnType("bigint") + .HasComment("会话标识。"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("消息内容。"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("消息类型(文字/图片/语音等)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsRead") + .HasColumnType("boolean") + .HasComment("是否已读。"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasComment("读取时间。"); + + b.Property("SenderType") + .HasColumnType("integer") + .HasComment("发送方类型。"); + + b.Property("SenderUserId") + .HasColumnType("bigint") + .HasComment("发送方用户 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ChatSessionId", "CreatedAt"); + + b.ToTable("chat_messages", null, t => + { + t.HasComment("会话消息。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentUserId") + .HasColumnType("bigint") + .HasComment("当前客服员工 ID。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CustomerUserId") + .HasColumnType("bigint") + .HasComment("顾客用户 ID。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasComment("结束时间。"); + + b.Property("IsBotActive") + .HasColumnType("boolean") + .HasComment("是否机器人接待中。"); + + b.Property("SessionCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("会话编号。"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasComment("开始时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("会话状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("所属门店(可空为平台)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SessionCode") + .IsUnique(); + + b.ToTable("chat_sessions", null, t => + { + t.HasComment("客服会话。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AssignedAgentId") + .HasColumnType("bigint") + .HasComment("指派的客服。"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone") + .HasComment("关闭时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CustomerUserId") + .HasColumnType("bigint") + .HasComment("客户用户 ID。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasComment("工单详情。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单(如有)。"); + + b.Property("Priority") + .HasColumnType("integer") + .HasComment("优先级。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("工单主题。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TicketNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("工单编号。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "TicketNo") + .IsUnique(); + + b.ToTable("support_tickets", null, t => + { + t.HasComment("客服工单。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttachmentsJson") + .HasColumnType("text") + .HasComment("附件 JSON。"); + + b.Property("AuthorUserId") + .HasColumnType("bigint") + .HasComment("评论人 ID。"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("评论内容。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsInternal") + .HasColumnType("boolean") + .HasComment("是否内部备注。"); + + b.Property("SupportTicketId") + .HasColumnType("bigint") + .HasComment("工单标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SupportTicketId"); + + b.ToTable("ticket_comments", null, t => + { + t.HasComment("工单评论/流转记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Deliveries.Entities.DeliveryEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryOrderId") + .HasColumnType("bigint") + .HasComment("配送单标识。"); + + b.Property("EventType") + .HasColumnType("integer") + .HasComment("事件类型。"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("事件描述。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("Payload") + .HasColumnType("text") + .HasComment("原始数据 JSON。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "DeliveryOrderId", "EventType"); + + b.ToTable("delivery_events", null, t => + { + t.HasComment("配送状态事件流水。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Deliveries.Entities.DeliveryOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CourierName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("骑手姓名。"); + + b.Property("CourierPhone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("骑手电话。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveredAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间。"); + + b.Property("DeliveryFee") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("配送费。"); + + b.Property("DispatchedAt") + .HasColumnType("timestamp with time zone") + .HasComment("下发时间。"); + + b.Property("FailureReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("异常原因。"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("PickedUpAt") + .HasColumnType("timestamp with time zone") + .HasComment("取餐时间。"); + + b.Property("Provider") + .HasColumnType("integer") + .HasComment("配送服务商。"); + + b.Property("ProviderOrderId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("第三方配送单号。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OrderId") + .IsUnique(); + + b.ToTable("delivery_orders", null, t => + { + t.HasComment("配送单。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Distribution.Entities.AffiliateOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffiliatePartnerId") + .HasColumnType("bigint") + .HasComment("推广人标识。"); + + b.Property("BuyerUserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EstimatedCommission") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("预计佣金。"); + + b.Property("OrderAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("订单金额。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单。"); + + b.Property("SettledAt") + .HasColumnType("timestamp with time zone") + .HasComment("结算完成时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AffiliatePartnerId", "OrderId") + .IsUnique(); + + b.ToTable("affiliate_orders", null, t => + { + t.HasComment("分销订单记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Distribution.Entities.AffiliatePartner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelType") + .HasColumnType("integer") + .HasComment("渠道类型。"); + + b.Property("CommissionRate") + .HasColumnType("numeric") + .HasComment("分成比例(0-1)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("昵称或渠道名称。"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("Remarks") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("审核备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID(如绑定平台账号)。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "DisplayName"); + + b.ToTable("affiliate_partners", null, t => + { + t.HasComment("分销/推广合作伙伴。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Distribution.Entities.AffiliatePayout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffiliatePartnerId") + .HasColumnType("bigint") + .HasComment("合作伙伴标识。"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("结算金额。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasComment("打款时间。"); + + b.Property("Period") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("结算周期描述。"); + + b.Property("Remarks") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AffiliatePartnerId", "Period") + .IsUnique(); + + b.ToTable("affiliate_payouts", null, t => + { + t.HasComment("佣金结算记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CheckInCampaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowMakeupCount") + .HasColumnType("integer") + .HasComment("支持补签次数。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("活动描述。"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasComment("结束日期。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("活动名称。"); + + b.Property("RewardsJson") + .IsRequired() + .HasColumnType("text") + .HasComment("连签奖励 JSON。"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasComment("开始日期。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name"); + + b.ToTable("checkin_campaigns", null, t => + { + t.HasComment("签到活动配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CheckInRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckInCampaignId") + .HasColumnType("bigint") + .HasComment("活动标识。"); + + b.Property("CheckInDate") + .HasColumnType("timestamp with time zone") + .HasComment("签到日期(本地)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsMakeup") + .HasColumnType("boolean") + .HasComment("是否补签。"); + + b.Property("RewardJson") + .IsRequired() + .HasColumnType("text") + .HasComment("获得奖励 JSON。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CheckInCampaignId", "UserId", "CheckInDate") + .IsUnique(); + + b.ToTable("checkin_records", null, t => + { + t.HasComment("用户签到记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CommunityComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorUserId") + .HasColumnType("bigint") + .HasComment("评论人。"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("评论内容。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasComment("状态。"); + + b.Property("ParentId") + .HasColumnType("bigint") + .HasComment("父级评论 ID。"); + + b.Property("PostId") + .HasColumnType("bigint") + .HasComment("动态标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "PostId", "CreatedAt"); + + b.ToTable("community_comments", null, t => + { + t.HasComment("社区评论。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CommunityPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorUserId") + .HasColumnType("bigint") + .HasComment("作者用户 ID。"); + + b.Property("CommentCount") + .HasColumnType("integer") + .HasComment("评论数。"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasComment("内容。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("LikeCount") + .HasColumnType("integer") + .HasComment("点赞数。"); + + b.Property("MediaJson") + .HasColumnType("text") + .HasComment("媒体资源 JSON。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AuthorUserId", "CreatedAt"); + + b.ToTable("community_posts", null, t => + { + t.HasComment("社区动态。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CommunityReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PostId") + .HasColumnType("bigint") + .HasComment("动态 ID。"); + + b.Property("ReactedAt") + .HasColumnType("timestamp with time zone") + .HasComment("时间戳。"); + + b.Property("ReactionType") + .HasColumnType("integer") + .HasComment("反应类型。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "PostId", "UserId") + .IsUnique(); + + b.ToTable("community_reactions", null, t => + { + t.HasComment("社区互动反馈。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.GroupBuying.Entities.GroupOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasComment("取消时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CurrentCount") + .HasColumnType("integer") + .HasComment("当前已参与人数。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndAt") + .HasColumnType("timestamp with time zone") + .HasComment("结束时间。"); + + b.Property("GroupOrderNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("拼单编号。"); + + b.Property("GroupPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("拼团价格。"); + + b.Property("LeaderUserId") + .HasColumnType("bigint") + .HasComment("团长用户 ID。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("关联商品或套餐。"); + + b.Property("StartAt") + .HasColumnType("timestamp with time zone") + .HasComment("开始时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("拼团状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("SucceededAt") + .HasColumnType("timestamp with time zone") + .HasComment("成团时间。"); + + b.Property("TargetCount") + .HasColumnType("integer") + .HasComment("成团需要的人数。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "GroupOrderNo") + .IsUnique(); + + b.ToTable("group_orders", null, t => + { + t.HasComment("拼单活动。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.GroupBuying.Entities.GroupParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("GroupOrderId") + .HasColumnType("bigint") + .HasComment("拼单活动标识。"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasComment("参与时间。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("对应订单标识。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("参与状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "GroupOrderId", "UserId") + .IsUnique(); + + b.ToTable("group_participants", null, t => + { + t.HasComment("拼单参与者。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Inventory.Entities.InventoryAdjustment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdjustmentType") + .HasColumnType("integer") + .HasComment("调整类型。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("InventoryItemId") + .HasColumnType("bigint") + .HasComment("对应的库存记录标识。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人标识。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("调整数量,正数增加,负数减少。"); + + b.Property("Reason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("原因说明。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "InventoryItemId", "OccurredAt"); + + b.ToTable("inventory_adjustments", null, t => + { + t.HasComment("库存调整记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Inventory.Entities.InventoryBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BatchNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("批次编号。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpireDate") + .HasColumnType("timestamp with time zone") + .HasComment("过期日期。"); + + b.Property("ProductSkuId") + .HasColumnType("bigint") + .HasComment("SKU 标识。"); + + b.Property("ProductionDate") + .HasColumnType("timestamp with time zone") + .HasComment("生产日期。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("入库数量。"); + + b.Property("RemainingQuantity") + .HasColumnType("integer") + .HasComment("剩余数量。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "ProductSkuId", "BatchNumber") + .IsUnique(); + + b.ToTable("inventory_batches", null, t => + { + t.HasComment("SKU 批次信息。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Inventory.Entities.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BatchNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("批次编号,可为空表示混批。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpireDate") + .HasColumnType("timestamp with time zone") + .HasComment("过期日期。"); + + b.Property("Location") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("储位或仓位信息。"); + + b.Property("ProductSkuId") + .HasColumnType("bigint") + .HasComment("SKU 标识。"); + + b.Property("QuantityOnHand") + .HasColumnType("integer") + .HasComment("可用库存。"); + + b.Property("QuantityReserved") + .HasColumnType("integer") + .HasComment("已锁定库存(订单占用)。"); + + b.Property("SafetyStock") + .HasColumnType("integer") + .HasComment("安全库存阈值。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "ProductSkuId", "BatchNumber"); + + b.ToTable("inventory_items", null, t => + { + t.HasComment("SKU 在门店的库存信息。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberGrowthLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChangeValue") + .HasColumnType("integer") + .HasComment("变动数量。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CurrentValue") + .HasColumnType("integer") + .HasComment("当前成长值。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasComment("会员标识。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MemberId", "OccurredAt"); + + b.ToTable("member_growth_logs", null, t => + { + t.HasComment("成长值变动日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberPointLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BalanceAfterChange") + .HasColumnType("integer") + .HasComment("变动后余额。"); + + b.Property("ChangeAmount") + .HasColumnType("integer") + .HasComment("变动数量,可为负值。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone") + .HasComment("过期时间(如适用)。"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasComment("会员标识。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("Reason") + .HasColumnType("integer") + .HasComment("变动原因。"); + + b.Property("SourceId") + .HasColumnType("bigint") + .HasComment("来源 ID(订单、活动等)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MemberId", "OccurredAt"); + + b.ToTable("member_point_ledgers", null, t => + { + t.HasComment("积分变动流水。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("头像。"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone") + .HasComment("生日。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("GrowthValue") + .HasColumnType("integer") + .HasComment("成长值/经验值。"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasComment("注册时间。"); + + b.Property("MemberTierId") + .HasColumnType("bigint") + .HasComment("当前会员等级 ID。"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("手机号。"); + + b.Property("Nickname") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("昵称。"); + + b.Property("PointsBalance") + .HasColumnType("integer") + .HasComment("会员积分余额。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("会员状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Mobile") + .IsUnique(); + + b.ToTable("member_profiles", null, t => + { + t.HasComment("会员档案。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BenefitsJson") + .IsRequired() + .HasColumnType("text") + .HasComment("等级权益(JSON)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("等级名称。"); + + b.Property("RequiredGrowth") + .HasColumnType("integer") + .HasComment("所需成长值。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("member_tiers", null, t => + { + t.HasComment("会员等级定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("详细地址。"); + + b.Property("BrandAlias") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("品牌简称或别名。"); + + b.Property("BrandName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("品牌名称(对外展示)。"); + + b.Property("BusinessLicenseImageUrl") + .HasColumnType("text") + .HasComment("营业执照扫描件地址。"); + + b.Property("BusinessLicenseNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("营业执照号。"); + + b.Property("Category") + .HasColumnType("text") + .HasComment("品牌所属品类,如火锅、咖啡等。"); + + b.Property("City") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在城市。"); + + b.Property("ContactEmail") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("联系邮箱。"); + + b.Property("ContactPhone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("District") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在区县。"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasComment("入驻时间。"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次审核时间。"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasComment("纬度信息。"); + + b.Property("LegalPerson") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("法人或负责人姓名。"); + + b.Property("LogoUrl") + .HasColumnType("text") + .HasComment("品牌 Logo。"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasComment("经度信息。"); + + b.Property("Province") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在省份。"); + + b.Property("ReviewRemarks") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("审核备注或驳回原因。"); + + b.Property("ServicePhone") + .HasColumnType("text") + .HasComment("客服电话。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("入驻状态。"); + + b.Property("SupportEmail") + .HasColumnType("text") + .HasComment("客服邮箱。"); + + b.Property("TaxNumber") + .HasColumnType("text") + .HasComment("税号/统一社会信用代码。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("merchants", null, t => + { + t.HasComment("商户主体信息,承载入驻和资质审核结果。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContractNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("合同编号。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasComment("合同结束时间。"); + + b.Property("FileUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("合同文件存储地址。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户标识。"); + + b.Property("SignedAt") + .HasColumnType("timestamp with time zone") + .HasComment("签署时间。"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasComment("合同开始时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("合同状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TerminatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("终止时间。"); + + b.Property("TerminationReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("终止原因。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MerchantId", "ContractNumber") + .IsUnique(); + + b.ToTable("merchant_contracts", null, t => + { + t.HasComment("商户合同记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DocumentNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("证照编号。"); + + b.Property("DocumentType") + .HasColumnType("integer") + .HasComment("证照类型。"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasComment("到期日期。"); + + b.Property("FileUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("证照文件链接。"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasComment("签发日期。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户标识。"); + + b.Property("Remarks") + .HasColumnType("text") + .HasComment("审核备注或驳回原因。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("审核状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MerchantId", "DocumentType"); + + b.ToTable("merchant_documents", null, t => + { + t.HasComment("商户提交的资质或证照材料。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantStaff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Email") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("邮箱地址。"); + + b.Property("IdentityUserId") + .HasColumnType("bigint") + .HasComment("登录账号 ID(指向统一身份体系)。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户标识。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("员工姓名。"); + + b.Property("PermissionsJson") + .HasColumnType("text") + .HasComment("自定义权限(JSON)。"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("手机号。"); + + b.Property("RoleType") + .HasColumnType("integer") + .HasComment("员工角色类型。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("员工状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("可选的关联门店 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MerchantId", "Phone"); + + b.ToTable("merchant_staff", null, t => + { + t.HasComment("商户员工账号,支持门店维度分配。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Navigation.Entities.MapLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Landmark") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("打车/导航落点描述。"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasComment("纬度。"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasComment("经度。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("名称。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("关联门店 ID,可空表示独立 POI。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("map_locations", null, t => + { + t.HasComment("地图 POI 信息,用于门店定位和推荐。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Navigation.Entities.NavigationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Channel") + .HasColumnType("integer") + .HasComment("来源通道(小程序、H5 等)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone") + .HasComment("请求时间。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店 ID。"); + + b.Property("TargetApp") + .HasColumnType("integer") + .HasComment("跳转的地图应用。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "UserId", "StoreId", "RequestedAt"); + + b.ToTable("navigation_requests", null, t => + { + t.HasComment("用户发起的导航请求日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Ordering.Entities.CartItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttributesJson") + .HasColumnType("text") + .HasComment("扩展 JSON(规格、加料选项等)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("商品或 SKU 标识。"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("商品名称快照。"); + + b.Property("ProductSkuId") + .HasColumnType("bigint") + .HasComment("SKU 标识。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("数量。"); + + b.Property("Remark") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("自定义备注(口味要求)。"); + + b.Property("ShoppingCartId") + .HasColumnType("bigint") + .HasComment("所属购物车标识。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("单价快照。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ShoppingCartId"); + + b.ToTable("cart_items", null, t => + { + t.HasComment("购物车条目。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Ordering.Entities.CartItemAddon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CartItemId") + .HasColumnType("bigint") + .HasComment("所属购物车条目。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExtraPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("附加价格。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("选项名称。"); + + b.Property("OptionId") + .HasColumnType("bigint") + .HasComment("选项 ID(可对应 ProductAddonOption)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.ToTable("cart_item_addons", null, t => + { + t.HasComment("购物车条目的加料/附加项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Ordering.Entities.CheckoutSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasComment("过期时间(UTC)。"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("会话 Token。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("会话状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.Property("ValidationResultJson") + .IsRequired() + .HasColumnType("text") + .HasComment("校验结果明细 JSON。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SessionToken") + .IsUnique(); + + b.ToTable("checkout_sessions", null, t => + { + t.HasComment("结账会话,记录校验上下文。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Ordering.Entities.ShoppingCart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryPreference") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("履约方式(堂食/自提/配送)缓存。"); + + b.Property("LastModifiedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次修改时间(UTC)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("购物车状态,包含正常/锁定。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TableContext") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("桌码或场景标识(扫码点餐)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "UserId", "StoreId") + .IsUnique(); + + b.ToTable("shopping_carts", null, t => + { + t.HasComment("用户购物车,按租户/门店隔离。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CancelReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("取消原因。"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasComment("取消时间。"); + + b.Property("Channel") + .HasColumnType("integer") + .HasComment("下单渠道。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CustomerName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("顾客姓名。"); + + b.Property("CustomerPhone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("顾客手机号。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryType") + .HasColumnType("integer") + .HasComment("履约类型。"); + + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("优惠金额。"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间。"); + + b.Property("ItemsAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("商品总额。"); + + b.Property("OrderNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("订单号。"); + + b.Property("PaidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("实付金额。"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasComment("支付时间。"); + + b.Property("PayableAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("应付金额。"); + + b.Property("PaymentStatus") + .HasColumnType("integer") + .HasComment("支付状态。"); + + b.Property("QueueNumber") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("排队号(如有)。"); + + b.Property("Remark") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注。"); + + b.Property("ReservationId") + .HasColumnType("bigint") + .HasComment("预约 ID。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店。"); + + b.Property("TableNo") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("就餐桌号。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OrderNo") + .IsUnique(); + + b.HasIndex("TenantId", "StoreId", "Status"); + + b.ToTable("orders", null, t => + { + t.HasComment("交易订单。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttributesJson") + .HasColumnType("text") + .HasComment("自定义属性 JSON。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("折扣金额。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("订单 ID。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("商品 ID。"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("商品名称。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("数量。"); + + b.Property("SkuName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("SKU/规格描述。"); + + b.Property("SubTotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("小计。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("单位。"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("单价。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("TenantId", "OrderId"); + + b.ToTable("order_items", null, t => + { + t.HasComment("订单明细。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.OrderStatusHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注信息。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人标识(可为空表示系统)。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("订单标识。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("变更后的状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OrderId", "OccurredAt"); + + b.ToTable("order_status_histories", null, t => + { + t.HasComment("订单状态流转记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.RefundRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("申请金额。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单标识。"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasComment("审核完成时间。"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("申请原因。"); + + b.Property("RefundNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("退款单号。"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone") + .HasComment("用户提交时间。"); + + b.Property("ReviewNotes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("审核备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("退款状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "RefundNo") + .IsUnique(); + + b.ToTable("refund_requests", null, t => + { + t.HasComment("售后/退款申请。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Payments.Entities.PaymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("支付金额。"); + + b.Property("ChannelTransactionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("第三方渠道单号。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Method") + .HasColumnType("integer") + .HasComment("支付方式。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单。"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasComment("支付完成时间。"); + + b.Property("Payload") + .HasColumnType("text") + .HasComment("原始回调内容。"); + + b.Property("Remark") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("错误/备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("支付状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TradeNo") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("平台交易号。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OrderId"); + + b.ToTable("payment_records", null, t => + { + t.HasComment("支付流水。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Payments.Entities.PaymentRefundRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("退款金额。"); + + b.Property("ChannelRefundId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("渠道退款流水号。"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单标识。"); + + b.Property("Payload") + .HasColumnType("text") + .HasComment("渠道返回的原始数据 JSON。"); + + b.Property("PaymentRecordId") + .HasColumnType("bigint") + .HasComment("原支付记录标识。"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone") + .HasComment("退款请求时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("退款状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "PaymentRecordId"); + + b.ToTable("payment_refund_records", null, t => + { + t.HasComment("支付渠道退款流水。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("bigint") + .HasComment("所属分类。"); + + b.Property("CoverImage") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("主图。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasColumnType("text") + .HasComment("商品描述。"); + + b.Property("EnableDelivery") + .HasColumnType("boolean") + .HasComment("支持配送。"); + + b.Property("EnableDineIn") + .HasColumnType("boolean") + .HasComment("支持堂食。"); + + b.Property("EnablePickup") + .HasColumnType("boolean") + .HasComment("支持自提。"); + + b.Property("GalleryImages") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("Gallery 图片逗号分隔。"); + + b.Property("IsFeatured") + .HasColumnType("boolean") + .HasComment("是否热门推荐。"); + + b.Property("MaxQuantityPerOrder") + .HasColumnType("integer") + .HasComment("最大每单限购。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("商品名称。"); + + b.Property("OriginalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("原价。"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("现价。"); + + b.Property("SpuCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("商品编码。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("商品状态。"); + + b.Property("StockQuantity") + .HasColumnType("integer") + .HasComment("库存数量(可选)。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("所属门店。"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("副标题/卖点。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("售卖单位(份/杯等)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SpuCode") + .IsUnique(); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("products", null, t => + { + t.HasComment("商品(SPU)信息。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductAddonGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsRequired") + .HasColumnType("boolean") + .HasComment("是否必选。"); + + b.Property("MaxSelect") + .HasColumnType("integer") + .HasComment("最大选择数量。"); + + b.Property("MinSelect") + .HasColumnType("integer") + .HasComment("最小选择数量。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("分组名称。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("所属商品。"); + + b.Property("SelectionType") + .HasColumnType("integer") + .HasComment("选择类型。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ProductId", "Name"); + + b.ToTable("product_addon_groups", null, t => + { + t.HasComment("加料/做法分组。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductAddonOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddonGroupId") + .HasColumnType("bigint") + .HasComment("所属加料分组。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExtraPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("附加价格。"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasComment("是否默认选项。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("选项名称。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.ToTable("product_addon_options", null, t => + { + t.HasComment("加料选项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductAttributeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsRequired") + .HasColumnType("boolean") + .HasComment("是否必选。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("分组名称,例如“辣度”“份量”。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("所属商品标识。"); + + b.Property("SelectionType") + .HasColumnType("integer") + .HasComment("选择类型(单选/多选)。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("显示排序。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("关联门店,可为空表示所有门店共享。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "Name"); + + b.ToTable("product_attribute_groups", null, t => + { + t.HasComment("商品规格/属性分组。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductAttributeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttributeGroupId") + .HasColumnType("bigint") + .HasComment("所属规格组。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExtraPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("附加价格。"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasComment("是否默认选中。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("选项名称。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AttributeGroupId", "Name") + .IsUnique(); + + b.ToTable("product_attribute_options", null, t => + { + t.HasComment("商品规格选项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("分类描述。"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("分类名称。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("所属门店。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("product_categories", null, t => + { + t.HasComment("商品分类。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductMediaAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Caption") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述或标题。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("MediaType") + .HasColumnType("integer") + .HasComment("媒体类型。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("商品标识。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("媒资链接。"); + + b.HasKey("Id"); + + b.ToTable("product_media_assets", null, t => + { + t.HasComment("商品媒资素材。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductPricingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConditionsJson") + .IsRequired() + .HasColumnType("text") + .HasComment("条件描述(JSON),如会员等级、渠道等。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone") + .HasComment("生效结束时间。"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("特殊价格。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("所属商品。"); + + b.Property("RuleType") + .HasColumnType("integer") + .HasComment("策略类型。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasComment("生效开始时间。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("WeekdaysJson") + .HasColumnType("text") + .HasComment("生效星期(JSON 数组)。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ProductId", "RuleType"); + + b.ToTable("product_pricing_rules", null, t => + { + t.HasComment("商品价格策略,支持会员价/时段价等。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductSku", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttributesJson") + .IsRequired() + .HasColumnType("text") + .HasComment("规格属性 JSON(记录选项 ID)。"); + + b.Property("Barcode") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("条形码。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("OriginalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("原价。"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("售价。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("所属商品标识。"); + + b.Property("SkuCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("SKU 编码。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StockQuantity") + .HasColumnType("integer") + .HasComment("可售库存。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Weight") + .HasPrecision(10, 3) + .HasColumnType("numeric(10,3)") + .HasComment("重量(千克)。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SkuCode") + .IsUnique(); + + b.ToTable("product_skus", null, t => + { + t.HasComment("商品 SKU,记录具体规格组合价格。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Queues.Entities.QueueTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalledAt") + .HasColumnType("timestamp with time zone") + .HasComment("叫号时间。"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasComment("取消时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EstimatedWaitMinutes") + .HasColumnType("integer") + .HasComment("预计等待分钟。"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasComment("过号时间。"); + + b.Property("PartySize") + .HasColumnType("integer") + .HasComment("就餐人数。"); + + b.Property("Remark") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("StoreId") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TicketNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("排队编号。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId"); + + b.HasIndex("TenantId", "StoreId", "TicketNumber") + .IsUnique(); + + b.ToTable("queue_tickets", null, t => + { + t.HasComment("排队叫号。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Reservations.Entities.Reservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasComment("取消时间。"); + + b.Property("CheckInCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("核销码/到店码。"); + + b.Property("CheckedInAt") + .HasColumnType("timestamp with time zone") + .HasComment("实际签到时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CustomerName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("客户姓名。"); + + b.Property("CustomerPhone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PeopleCount") + .HasColumnType("integer") + .HasComment("用餐人数。"); + + b.Property("Remark") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注。"); + + b.Property("ReservationNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("预约号。"); + + b.Property("ReservationTime") + .HasColumnType("timestamp with time zone") + .HasComment("预约时间(UTC)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店。"); + + b.Property("TablePreference") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("桌型/标签。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ReservationNo") + .IsUnique(); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("reservations", null, t => + { + t.HasComment("预约/预订记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("详细地址。"); + + b.Property("Announcement") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("门店公告。"); + + b.Property("BusinessHours") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("门店营业时段描述(备用字符串)。"); + + b.Property("City") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在城市。"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("门店编码,便于扫码及外部对接。"); + + b.Property("Country") + .HasColumnType("text") + .HasComment("所在国家或地区。"); + + b.Property("CoverImageUrl") + .HasColumnType("text") + .HasComment("门店海报。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryRadiusKm") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)") + .HasComment("默认配送半径(公里)。"); + + b.Property("Description") + .HasColumnType("text") + .HasComment("门店描述或公告。"); + + b.Property("District") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("区县信息。"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasComment("纬度。"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasComment("高德/腾讯地图经度。"); + + b.Property("ManagerName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("门店负责人姓名。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户标识。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("门店名称。"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("Province") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在省份。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("门店当前运营状态。"); + + b.Property("SupportsDelivery") + .HasColumnType("boolean") + .HasComment("是否支持配送。"); + + b.Property("SupportsDineIn") + .HasColumnType("boolean") + .HasComment("是否支持堂食。"); + + b.Property("SupportsPickup") + .HasColumnType("boolean") + .HasComment("是否支持自提。"); + + b.Property("SupportsQueueing") + .HasColumnType("boolean") + .HasComment("支持排队叫号。"); + + b.Property("SupportsReservation") + .HasColumnType("boolean") + .HasComment("支持预约。"); + + b.Property("Tags") + .HasColumnType("text") + .HasComment("门店标签(逗号分隔)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.HasIndex("TenantId", "MerchantId"); + + b.ToTable("stores", null, t => + { + t.HasComment("门店信息,承载营业配置与能力。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreBusinessHour", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CapacityLimit") + .HasColumnType("integer") + .HasComment("最大接待容量或单量限制。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DayOfWeek") + .HasColumnType("integer") + .HasComment("星期几,0 表示周日。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndTime") + .HasColumnType("interval") + .HasComment("结束时间(本地时间)。"); + + b.Property("HourType") + .HasColumnType("integer") + .HasComment("时段类型(正常营业、休息、预约等)。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("StartTime") + .HasColumnType("interval") + .HasComment("开始时间(本地时间)。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "DayOfWeek"); + + b.ToTable("store_business_hours", null, t => + { + t.HasComment("门店营业时段配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreDeliveryZone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryFee") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("配送费。"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasComment("预计送达分钟。"); + + b.Property("MinimumOrderAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("起送价。"); + + b.Property("PolygonGeoJson") + .IsRequired() + .HasColumnType("text") + .HasComment("GeoJSON 表示的多边形范围。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("ZoneName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("区域名称。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "ZoneName"); + + b.ToTable("store_delivery_zones", null, t => + { + t.HasComment("门店配送范围配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreEmployeeShift", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndTime") + .HasColumnType("interval") + .HasComment("结束时间。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("RoleType") + .HasColumnType("integer") + .HasComment("排班角色。"); + + b.Property("ShiftDate") + .HasColumnType("timestamp with time zone") + .HasComment("班次日期。"); + + b.Property("StaffId") + .HasColumnType("bigint") + .HasComment("员工标识。"); + + b.Property("StartTime") + .HasColumnType("interval") + .HasComment("开始时间。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "ShiftDate", "StaffId") + .IsUnique(); + + b.ToTable("store_employee_shifts", null, t => + { + t.HasComment("门店员工排班记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("Date") + .HasColumnType("timestamp with time zone") + .HasComment("日期。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsClosed") + .HasColumnType("boolean") + .HasComment("是否全天闭店。"); + + b.Property("Reason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("说明内容。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "Date") + .IsUnique(); + + b.ToTable("store_holidays", null, t => + { + t.HasComment("门店休息日或特殊营业日。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreTable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("bigint") + .HasComment("所在区域 ID。"); + + b.Property("Capacity") + .HasColumnType("integer") + .HasComment("可容纳人数。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("QrCodeUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("桌码二维码地址。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前桌台状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TableCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("桌码。"); + + b.Property("Tags") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("桌台标签(堂食、快餐等)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "TableCode") + .IsUnique(); + + b.ToTable("store_tables", null, t => + { + t.HasComment("桌台信息与二维码绑定。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreTableArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("区域描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("区域名称。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "Name") + .IsUnique(); + + b.ToTable("store_table_areas", null, t => + { + t.HasComment("门店桌台区域配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("text") + .HasComment("详细地址信息。"); + + b.Property("City") + .HasColumnType("text") + .HasComment("所在城市。"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("租户短编码,作为跨系统引用的唯一标识。"); + + b.Property("ContactEmail") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("主联系人邮箱。"); + + b.Property("ContactName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("主联系人姓名。"); + + b.Property("ContactPhone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("主联系人电话。"); + + b.Property("Country") + .HasColumnType("text") + .HasComment("所在国家/地区。"); + + b.Property("CoverImageUrl") + .HasColumnType("text") + .HasComment("品牌海报或封面图。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone") + .HasComment("服务生效时间(UTC)。"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone") + .HasComment("服务到期时间(UTC)。"); + + b.Property("Industry") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所属行业,如餐饮、零售等。"); + + b.Property("LegalEntityName") + .HasColumnType("text") + .HasComment("法人或公司主体名称。"); + + b.Property("LogoUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("LOGO 图片地址。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("租户全称或品牌名称。"); + + b.Property("PrimaryOwnerUserId") + .HasColumnType("bigint") + .HasComment("系统内对应的租户所有者账号 ID。"); + + b.Property("Province") + .HasColumnType("text") + .HasComment("所在省份或州。"); + + b.Property("Remarks") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注信息,用于运营记录特殊说明。"); + + b.Property("ShortName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("对外展示的简称。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("租户当前状态,涵盖审核、启用、停用等场景。"); + + b.Property("SuspendedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次暂停服务时间。"); + + b.Property("SuspensionReason") + .HasColumnType("text") + .HasComment("暂停或终止的原因说明。"); + + b.Property("Tags") + .HasColumnType("text") + .HasComment("业务标签集合(逗号分隔)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Website") + .HasColumnType("text") + .HasComment("官网或主要宣传链接。"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("tenants", null, t => + { + t.HasComment("平台租户信息,描述租户的生命周期与基础资料。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantBillingStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountDue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("应付金额。"); + + b.Property("AmountPaid") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("实付金额。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone") + .HasComment("到期日。"); + + b.Property("LineItemsJson") + .HasColumnType("text") + .HasComment("账单明细 JSON,记录各项费用。"); + + b.Property("PeriodEnd") + .HasColumnType("timestamp with time zone") + .HasComment("账单周期结束时间。"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone") + .HasComment("账单周期开始时间。"); + + b.Property("StatementNo") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("账单编号,供对账查询。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前付款状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StatementNo") + .IsUnique(); + + b.ToTable("tenant_billing_statements", null, t => + { + t.HasComment("租户账单,用于呈现周期性收费。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Channel") + .HasColumnType("integer") + .HasComment("发布通道(站内、邮件、短信等)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("通知正文。"); + + b.Property("MetadataJson") + .HasColumnType("text") + .HasComment("附加元数据 JSON。"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasComment("租户是否已阅读。"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasComment("推送时间。"); + + b.Property("Severity") + .HasColumnType("integer") + .HasComment("通知重要级别。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("通知标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Channel", "SentAt"); + + b.ToTable("tenant_notifications", null, t => + { + t.HasComment("面向租户的站内通知或消息推送。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantPackage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("套餐描述,包含适用场景、权益等。"); + + b.Property("FeaturePoliciesJson") + .HasColumnType("text") + .HasComment("权益明细 JSON,记录自定义特性开关。"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasComment("是否仍可售卖。"); + + b.Property("MaxAccountCount") + .HasColumnType("integer") + .HasComment("允许创建的最大账号数。"); + + b.Property("MaxDeliveryOrders") + .HasColumnType("integer") + .HasComment("每月可调用的配送单数量上限。"); + + b.Property("MaxSmsCredits") + .HasColumnType("integer") + .HasComment("每月短信额度上限。"); + + b.Property("MaxStorageGb") + .HasColumnType("integer") + .HasComment("存储容量上限(GB)。"); + + b.Property("MaxStoreCount") + .HasColumnType("integer") + .HasComment("允许的最大门店数。"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric") + .HasComment("月付价格,单位:人民币元。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("套餐名称,展示给租户的简称。"); + + b.Property("PackageType") + .HasColumnType("integer") + .HasComment("套餐分类(试用、标准、旗舰等)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("YearlyPrice") + .HasColumnType("numeric") + .HasComment("年付价格,单位:人民币元。"); + + b.HasKey("Id"); + + b.ToTable("tenant_packages", null, t => + { + t.HasComment("平台提供的租户套餐定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantQuotaUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("LastResetAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次重置时间。"); + + b.Property("LimitValue") + .HasColumnType("numeric") + .HasComment("当前配额上限。"); + + b.Property("QuotaType") + .HasColumnType("integer") + .HasComment("配额类型,例如门店数、短信条数等。"); + + b.Property("ResetCycle") + .HasColumnType("text") + .HasComment("配额刷新周期描述(如月、年)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UsedValue") + .HasColumnType("numeric") + .HasComment("已消耗的数量。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "QuotaType") + .IsUnique(); + + b.ToTable("tenant_quota_usages", null, t => + { + t.HasComment("租户配额使用情况快照。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoRenew") + .HasColumnType("boolean") + .HasComment("是否开启自动续费。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone") + .HasComment("订阅生效时间(UTC)。"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone") + .HasComment("订阅到期时间(UTC)。"); + + b.Property("NextBillingDate") + .HasColumnType("timestamp with time zone") + .HasComment("下一个计费时间,配合自动续费使用。"); + + b.Property("Notes") + .HasColumnType("text") + .HasComment("运营备注信息。"); + + b.Property("ScheduledPackageId") + .HasColumnType("bigint") + .HasComment("若已排期升降配,对应的新套餐 ID。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("订阅当前状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TenantPackageId") + .HasColumnType("bigint") + .HasComment("当前订阅关联的套餐标识。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "TenantPackageId"); + + b.ToTable("tenant_subscriptions", null, t => + { + t.HasComment("租户套餐订阅记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.OrderItem", b => + { + b.HasOne("TakeoutSaaS.Domain.Orders.Entities.Order", null) + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.cs new file mode 100644 index 0000000..4b31527 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.cs @@ -0,0 +1,2550 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + /// + public partial class InitSnowflake_App : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "affiliate_orders", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AffiliatePartnerId = table.Column(type: "bigint", nullable: false, comment: "推广人标识。"), + OrderId = table.Column(type: "bigint", nullable: false, comment: "关联订单。"), + BuyerUserId = table.Column(type: "bigint", nullable: false, comment: "用户 ID。"), + OrderAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "订单金额。"), + EstimatedCommission = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "预计佣金。"), + Status = table.Column(type: "integer", nullable: false, comment: "当前状态。"), + SettledAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "结算完成时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_affiliate_orders", x => x.Id); + }, + comment: "分销订单记录。"); + + migrationBuilder.CreateTable( + name: "affiliate_partners", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "bigint", nullable: true, comment: "用户 ID(如绑定平台账号)。"), + DisplayName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "昵称或渠道名称。"), + Phone = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "联系电话。"), + ChannelType = table.Column(type: "integer", nullable: false, comment: "渠道类型。"), + CommissionRate = table.Column(type: "numeric", nullable: false, comment: "分成比例(0-1)。"), + Status = table.Column(type: "integer", nullable: false, comment: "当前状态。"), + Remarks = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "审核备注。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_affiliate_partners", x => x.Id); + }, + comment: "分销/推广合作伙伴。"); + + migrationBuilder.CreateTable( + name: "affiliate_payouts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AffiliatePartnerId = table.Column(type: "bigint", nullable: false, comment: "合作伙伴标识。"), + Period = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "结算周期描述。"), + Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "结算金额。"), + Status = table.Column(type: "integer", nullable: false, comment: "状态。"), + PaidAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "打款时间。"), + Remarks = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "备注。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_affiliate_payouts", x => x.Id); + }, + comment: "佣金结算记录。"); + + migrationBuilder.CreateTable( + name: "cart_item_addons", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CartItemId = table.Column(type: "bigint", nullable: false, comment: "所属购物车条目。"), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "选项名称。"), + ExtraPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "附加价格。"), + OptionId = table.Column(type: "bigint", nullable: true, comment: "选项 ID(可对应 ProductAddonOption)。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_cart_item_addons", x => x.Id); + }, + comment: "购物车条目的加料/附加项。"); + + migrationBuilder.CreateTable( + name: "cart_items", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ShoppingCartId = table.Column(type: "bigint", nullable: false, comment: "所属购物车标识。"), + ProductId = table.Column(type: "bigint", nullable: false, comment: "商品或 SKU 标识。"), + ProductSkuId = table.Column(type: "bigint", nullable: true, comment: "SKU 标识。"), + ProductName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "商品名称快照。"), + UnitPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "单价快照。"), + Quantity = table.Column(type: "integer", nullable: false, comment: "数量。"), + Remark = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "自定义备注(口味要求)。"), + Status = table.Column(type: "integer", nullable: false, comment: "状态。"), + AttributesJson = table.Column(type: "text", nullable: true, comment: "扩展 JSON(规格、加料选项等)。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_cart_items", x => x.Id); + }, + comment: "购物车条目。"); + + migrationBuilder.CreateTable( + name: "chat_messages", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ChatSessionId = table.Column(type: "bigint", nullable: false, comment: "会话标识。"), + SenderType = table.Column(type: "integer", nullable: false, comment: "发送方类型。"), + SenderUserId = table.Column(type: "bigint", nullable: true, comment: "发送方用户 ID。"), + Content = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false, comment: "消息内容。"), + ContentType = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "消息类型(文字/图片/语音等)。"), + IsRead = table.Column(type: "boolean", nullable: false, comment: "是否已读。"), + ReadAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "读取时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_chat_messages", x => x.Id); + }, + comment: "会话消息。"); + + migrationBuilder.CreateTable( + name: "chat_sessions", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SessionCode = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "会话编号。"), + CustomerUserId = table.Column(type: "bigint", nullable: false, comment: "顾客用户 ID。"), + AgentUserId = table.Column(type: "bigint", nullable: true, comment: "当前客服员工 ID。"), + StoreId = table.Column(type: "bigint", nullable: true, comment: "所属门店(可空为平台)。"), + Status = table.Column(type: "integer", nullable: false, comment: "会话状态。"), + IsBotActive = table.Column(type: "boolean", nullable: false, comment: "是否机器人接待中。"), + StartedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "开始时间。"), + EndedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "结束时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_chat_sessions", x => x.Id); + }, + comment: "客服会话。"); + + migrationBuilder.CreateTable( + name: "checkin_campaigns", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "活动名称。"), + Description = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "活动描述。"), + StartDate = table.Column(type: "timestamp with time zone", nullable: false, comment: "开始日期。"), + EndDate = table.Column(type: "timestamp with time zone", nullable: false, comment: "结束日期。"), + AllowMakeupCount = table.Column(type: "integer", nullable: false, comment: "支持补签次数。"), + RewardsJson = table.Column(type: "text", nullable: false, comment: "连签奖励 JSON。"), + Status = table.Column(type: "integer", nullable: false, comment: "状态。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_checkin_campaigns", x => x.Id); + }, + comment: "签到活动配置。"); + + migrationBuilder.CreateTable( + name: "checkin_records", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CheckInCampaignId = table.Column(type: "bigint", nullable: false, comment: "活动标识。"), + UserId = table.Column(type: "bigint", nullable: false, comment: "用户标识。"), + CheckInDate = table.Column(type: "timestamp with time zone", nullable: false, comment: "签到日期(本地)。"), + IsMakeup = table.Column(type: "boolean", nullable: false, comment: "是否补签。"), + RewardJson = table.Column(type: "text", nullable: false, comment: "获得奖励 JSON。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_checkin_records", x => x.Id); + }, + comment: "用户签到记录。"); + + migrationBuilder.CreateTable( + name: "checkout_sessions", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "bigint", nullable: false, comment: "用户标识。"), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + SessionToken = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "会话 Token。"), + Status = table.Column(type: "integer", nullable: false, comment: "会话状态。"), + ValidationResultJson = table.Column(type: "text", nullable: false, comment: "校验结果明细 JSON。"), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "过期时间(UTC)。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_checkout_sessions", x => x.Id); + }, + comment: "结账会话,记录校验上下文。"); + + migrationBuilder.CreateTable( + name: "community_comments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PostId = table.Column(type: "bigint", nullable: false, comment: "动态标识。"), + AuthorUserId = table.Column(type: "bigint", nullable: false, comment: "评论人。"), + Content = table.Column(type: "character varying(512)", maxLength: 512, nullable: false, comment: "评论内容。"), + ParentId = table.Column(type: "bigint", nullable: true, comment: "父级评论 ID。"), + IsDeleted = table.Column(type: "boolean", nullable: false, comment: "状态。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_community_comments", x => x.Id); + }, + comment: "社区评论。"); + + migrationBuilder.CreateTable( + name: "community_posts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AuthorUserId = table.Column(type: "bigint", nullable: false, comment: "作者用户 ID。"), + Title = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "标题。"), + Content = table.Column(type: "text", nullable: false, comment: "内容。"), + MediaJson = table.Column(type: "text", nullable: true, comment: "媒体资源 JSON。"), + Status = table.Column(type: "integer", nullable: false, comment: "状态。"), + LikeCount = table.Column(type: "integer", nullable: false, comment: "点赞数。"), + CommentCount = table.Column(type: "integer", nullable: false, comment: "评论数。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_community_posts", x => x.Id); + }, + comment: "社区动态。"); + + migrationBuilder.CreateTable( + name: "community_reactions", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PostId = table.Column(type: "bigint", nullable: false, comment: "动态 ID。"), + UserId = table.Column(type: "bigint", nullable: false, comment: "用户 ID。"), + ReactionType = table.Column(type: "integer", nullable: false, comment: "反应类型。"), + ReactedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "时间戳。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_community_reactions", x => x.Id); + }, + comment: "社区互动反馈。"); + + migrationBuilder.CreateTable( + name: "coupon_templates", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "模板名称。"), + CouponType = table.Column(type: "integer", nullable: false, comment: "券类型。"), + Value = table.Column(type: "numeric", nullable: false, comment: "面值或折扣额度。"), + DiscountCap = table.Column(type: "numeric", nullable: true, comment: "折扣上限(针对折扣券)。"), + MinimumSpend = table.Column(type: "numeric", nullable: true, comment: "最低消费门槛。"), + ValidFrom = table.Column(type: "timestamp with time zone", nullable: true, comment: "可用开始时间。"), + ValidTo = table.Column(type: "timestamp with time zone", nullable: true, comment: "可用结束时间。"), + RelativeValidDays = table.Column(type: "integer", nullable: true, comment: "有效天数(相对发放时间)。"), + TotalQuantity = table.Column(type: "integer", nullable: true, comment: "总发放数量上限。"), + ClaimedQuantity = table.Column(type: "integer", nullable: false, comment: "已领取数量。"), + StoreScopeJson = table.Column(type: "text", nullable: true, comment: "适用门店 ID 集合(JSON)。"), + ProductScopeJson = table.Column(type: "text", nullable: true, comment: "适用品类或商品范围(JSON)。"), + ChannelsJson = table.Column(type: "text", nullable: true, comment: "发放渠道(JSON)。"), + AllowStack = table.Column(type: "boolean", nullable: false, comment: "是否允许叠加其他优惠。"), + Status = table.Column(type: "integer", nullable: false, comment: "状态。"), + Description = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "备注。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_coupon_templates", x => x.Id); + }, + comment: "优惠券模板。"); + + migrationBuilder.CreateTable( + name: "coupons", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CouponTemplateId = table.Column(type: "bigint", nullable: false, comment: "模板标识。"), + Code = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "券码或序列号。"), + UserId = table.Column(type: "bigint", nullable: false, comment: "归属用户。"), + OrderId = table.Column(type: "bigint", nullable: true, comment: "订单 ID(已使用时记录)。"), + Status = table.Column(type: "integer", nullable: false, comment: "状态。"), + IssuedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "发放时间。"), + UsedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "使用时间。"), + ExpireAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "到期时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_coupons", x => x.Id); + }, + comment: "用户领取的券。"); + + migrationBuilder.CreateTable( + name: "delivery_events", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + DeliveryOrderId = table.Column(type: "bigint", nullable: false, comment: "配送单标识。"), + EventType = table.Column(type: "integer", nullable: false, comment: "事件类型。"), + Message = table.Column(type: "character varying(256)", maxLength: 256, nullable: false, comment: "事件描述。"), + Payload = table.Column(type: "text", nullable: true, comment: "原始数据 JSON。"), + OccurredAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "发生时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_delivery_events", x => x.Id); + }, + comment: "配送状态事件流水。"); + + migrationBuilder.CreateTable( + name: "delivery_orders", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + OrderId = table.Column(type: "bigint", nullable: false), + Provider = table.Column(type: "integer", nullable: false, comment: "配送服务商。"), + ProviderOrderId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "第三方配送单号。"), + Status = table.Column(type: "integer", nullable: false, comment: "状态。"), + DeliveryFee = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "配送费。"), + CourierName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "骑手姓名。"), + CourierPhone = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "骑手电话。"), + DispatchedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "下发时间。"), + PickedUpAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "取餐时间。"), + DeliveredAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "完成时间。"), + FailureReason = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "异常原因。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_delivery_orders", x => x.Id); + }, + comment: "配送单。"); + + migrationBuilder.CreateTable( + name: "group_orders", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + ProductId = table.Column(type: "bigint", nullable: false, comment: "关联商品或套餐。"), + GroupOrderNo = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "拼单编号。"), + LeaderUserId = table.Column(type: "bigint", nullable: false, comment: "团长用户 ID。"), + TargetCount = table.Column(type: "integer", nullable: false, comment: "成团需要的人数。"), + CurrentCount = table.Column(type: "integer", nullable: false, comment: "当前已参与人数。"), + GroupPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "拼团价格。"), + StartAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "开始时间。"), + EndAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "结束时间。"), + Status = table.Column(type: "integer", nullable: false, comment: "拼团状态。"), + SucceededAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "成团时间。"), + CancelledAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "取消时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_group_orders", x => x.Id); + }, + comment: "拼单活动。"); + + migrationBuilder.CreateTable( + name: "group_participants", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + GroupOrderId = table.Column(type: "bigint", nullable: false, comment: "拼单活动标识。"), + OrderId = table.Column(type: "bigint", nullable: false, comment: "对应订单标识。"), + UserId = table.Column(type: "bigint", nullable: false, comment: "用户标识。"), + Status = table.Column(type: "integer", nullable: false, comment: "参与状态。"), + JoinedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "参与时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_group_participants", x => x.Id); + }, + comment: "拼单参与者。"); + + migrationBuilder.CreateTable( + name: "inventory_adjustments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + InventoryItemId = table.Column(type: "bigint", nullable: false, comment: "对应的库存记录标识。"), + AdjustmentType = table.Column(type: "integer", nullable: false, comment: "调整类型。"), + Quantity = table.Column(type: "integer", nullable: false, comment: "调整数量,正数增加,负数减少。"), + Reason = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "原因说明。"), + OperatorId = table.Column(type: "bigint", nullable: true, comment: "操作人标识。"), + OccurredAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "发生时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_inventory_adjustments", x => x.Id); + }, + comment: "库存调整记录。"); + + migrationBuilder.CreateTable( + name: "inventory_batches", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + ProductSkuId = table.Column(type: "bigint", nullable: false, comment: "SKU 标识。"), + BatchNumber = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "批次编号。"), + ProductionDate = table.Column(type: "timestamp with time zone", nullable: true, comment: "生产日期。"), + ExpireDate = table.Column(type: "timestamp with time zone", nullable: true, comment: "过期日期。"), + Quantity = table.Column(type: "integer", nullable: false, comment: "入库数量。"), + RemainingQuantity = table.Column(type: "integer", nullable: false, comment: "剩余数量。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_inventory_batches", x => x.Id); + }, + comment: "SKU 批次信息。"); + + migrationBuilder.CreateTable( + name: "inventory_items", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + ProductSkuId = table.Column(type: "bigint", nullable: false, comment: "SKU 标识。"), + BatchNumber = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "批次编号,可为空表示混批。"), + QuantityOnHand = table.Column(type: "integer", nullable: false, comment: "可用库存。"), + QuantityReserved = table.Column(type: "integer", nullable: false, comment: "已锁定库存(订单占用)。"), + SafetyStock = table.Column(type: "integer", nullable: true, comment: "安全库存阈值。"), + Location = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "储位或仓位信息。"), + ExpireDate = table.Column(type: "timestamp with time zone", nullable: true, comment: "过期日期。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_inventory_items", x => x.Id); + }, + comment: "SKU 在门店的库存信息。"); + + migrationBuilder.CreateTable( + name: "map_locations", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: true, comment: "关联门店 ID,可空表示独立 POI。"), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "名称。"), + Address = table.Column(type: "character varying(256)", maxLength: 256, nullable: false, comment: "地址。"), + Longitude = table.Column(type: "double precision", nullable: false, comment: "经度。"), + Latitude = table.Column(type: "double precision", nullable: false, comment: "纬度。"), + Landmark = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "打车/导航落点描述。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_map_locations", x => x.Id); + }, + comment: "地图 POI 信息,用于门店定位和推荐。"); + + migrationBuilder.CreateTable( + name: "member_growth_logs", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MemberId = table.Column(type: "bigint", nullable: false, comment: "会员标识。"), + ChangeValue = table.Column(type: "integer", nullable: false, comment: "变动数量。"), + CurrentValue = table.Column(type: "integer", nullable: false, comment: "当前成长值。"), + Notes = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "备注。"), + OccurredAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "发生时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_member_growth_logs", x => x.Id); + }, + comment: "成长值变动日志。"); + + migrationBuilder.CreateTable( + name: "member_point_ledgers", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MemberId = table.Column(type: "bigint", nullable: false, comment: "会员标识。"), + ChangeAmount = table.Column(type: "integer", nullable: false, comment: "变动数量,可为负值。"), + BalanceAfterChange = table.Column(type: "integer", nullable: false, comment: "变动后余额。"), + Reason = table.Column(type: "integer", nullable: false, comment: "变动原因。"), + SourceId = table.Column(type: "bigint", nullable: true, comment: "来源 ID(订单、活动等)。"), + OccurredAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "发生时间。"), + ExpireAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "过期时间(如适用)。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_member_point_ledgers", x => x.Id); + }, + comment: "积分变动流水。"); + + migrationBuilder.CreateTable( + name: "member_profiles", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "bigint", nullable: false, comment: "用户标识。"), + Mobile = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "手机号。"), + Nickname = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "昵称。"), + AvatarUrl = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "头像。"), + MemberTierId = table.Column(type: "bigint", nullable: true, comment: "当前会员等级 ID。"), + Status = table.Column(type: "integer", nullable: false, comment: "会员状态。"), + PointsBalance = table.Column(type: "integer", nullable: false, comment: "会员积分余额。"), + GrowthValue = table.Column(type: "integer", nullable: false, comment: "成长值/经验值。"), + BirthDate = table.Column(type: "timestamp with time zone", nullable: true, comment: "生日。"), + JoinedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "注册时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_member_profiles", x => x.Id); + }, + comment: "会员档案。"); + + migrationBuilder.CreateTable( + name: "member_tiers", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "等级名称。"), + RequiredGrowth = table.Column(type: "integer", nullable: false, comment: "所需成长值。"), + BenefitsJson = table.Column(type: "text", nullable: false, comment: "等级权益(JSON)。"), + SortOrder = table.Column(type: "integer", nullable: false, comment: "排序值。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_member_tiers", x => x.Id); + }, + comment: "会员等级定义。"); + + migrationBuilder.CreateTable( + name: "merchant_contracts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MerchantId = table.Column(type: "bigint", nullable: false, comment: "所属商户标识。"), + ContractNumber = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "合同编号。"), + Status = table.Column(type: "integer", nullable: false, comment: "合同状态。"), + StartDate = table.Column(type: "timestamp with time zone", nullable: false, comment: "合同开始时间。"), + EndDate = table.Column(type: "timestamp with time zone", nullable: false, comment: "合同结束时间。"), + FileUrl = table.Column(type: "character varying(512)", maxLength: 512, nullable: false, comment: "合同文件存储地址。"), + SignedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "签署时间。"), + TerminatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "终止时间。"), + TerminationReason = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "终止原因。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_merchant_contracts", x => x.Id); + }, + comment: "商户合同记录。"); + + migrationBuilder.CreateTable( + name: "merchant_documents", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MerchantId = table.Column(type: "bigint", nullable: false, comment: "所属商户标识。"), + DocumentType = table.Column(type: "integer", nullable: false, comment: "证照类型。"), + Status = table.Column(type: "integer", nullable: false, comment: "审核状态。"), + FileUrl = table.Column(type: "character varying(512)", maxLength: 512, nullable: false, comment: "证照文件链接。"), + DocumentNumber = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "证照编号。"), + IssuedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "签发日期。"), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "到期日期。"), + Remarks = table.Column(type: "text", nullable: true, comment: "审核备注或驳回原因。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_merchant_documents", x => x.Id); + }, + comment: "商户提交的资质或证照材料。"); + + migrationBuilder.CreateTable( + name: "merchant_staff", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MerchantId = table.Column(type: "bigint", nullable: false, comment: "所属商户标识。"), + StoreId = table.Column(type: "bigint", nullable: true, comment: "可选的关联门店 ID。"), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "员工姓名。"), + Phone = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "手机号。"), + Email = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "邮箱地址。"), + IdentityUserId = table.Column(type: "bigint", nullable: true, comment: "登录账号 ID(指向统一身份体系)。"), + RoleType = table.Column(type: "integer", nullable: false, comment: "员工角色类型。"), + Status = table.Column(type: "integer", nullable: false, comment: "员工状态。"), + PermissionsJson = table.Column(type: "text", nullable: true, comment: "自定义权限(JSON)。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_merchant_staff", x => x.Id); + }, + comment: "商户员工账号,支持门店维度分配。"); + + migrationBuilder.CreateTable( + name: "merchants", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + BrandName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "品牌名称(对外展示)。"), + BrandAlias = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "品牌简称或别名。"), + LogoUrl = table.Column(type: "text", nullable: true, comment: "品牌 Logo。"), + Category = table.Column(type: "text", nullable: true, comment: "品牌所属品类,如火锅、咖啡等。"), + BusinessLicenseNumber = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "营业执照号。"), + BusinessLicenseImageUrl = table.Column(type: "text", nullable: true, comment: "营业执照扫描件地址。"), + TaxNumber = table.Column(type: "text", nullable: true, comment: "税号/统一社会信用代码。"), + LegalPerson = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "法人或负责人姓名。"), + ContactPhone = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "联系电话。"), + ContactEmail = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "联系邮箱。"), + ServicePhone = table.Column(type: "text", nullable: true, comment: "客服电话。"), + SupportEmail = table.Column(type: "text", nullable: true, comment: "客服邮箱。"), + Province = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "所在省份。"), + City = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "所在城市。"), + District = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "所在区县。"), + Address = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "详细地址。"), + Longitude = table.Column(type: "double precision", nullable: true, comment: "经度信息。"), + Latitude = table.Column(type: "double precision", nullable: true, comment: "纬度信息。"), + Status = table.Column(type: "integer", nullable: false, comment: "入驻状态。"), + ReviewRemarks = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "审核备注或驳回原因。"), + JoinedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "入驻时间。"), + LastReviewedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次审核时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_merchants", x => x.Id); + }, + comment: "商户主体信息,承载入驻和资质审核结果。"); + + migrationBuilder.CreateTable( + name: "metric_alert_rules", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MetricDefinitionId = table.Column(type: "bigint", nullable: false, comment: "关联指标。"), + ConditionJson = table.Column(type: "text", nullable: false, comment: "触发条件 JSON。"), + Severity = table.Column(type: "integer", nullable: false, comment: "告警级别。"), + NotificationChannels = table.Column(type: "character varying(256)", maxLength: 256, nullable: false, comment: "通知渠道。"), + Enabled = table.Column(type: "boolean", nullable: false, comment: "是否启用。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_metric_alert_rules", x => x.Id); + }, + comment: "指标告警规则。"); + + migrationBuilder.CreateTable( + name: "metric_definitions", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "指标编码。"), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "指标名称。"), + Description = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "说明。"), + DimensionsJson = table.Column(type: "text", nullable: true, comment: "维度描述 JSON。"), + DefaultAggregation = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "默认聚合方式。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_metric_definitions", x => x.Id); + }, + comment: "指标定义,描述可观测的数据点。"); + + migrationBuilder.CreateTable( + name: "metric_snapshots", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MetricDefinitionId = table.Column(type: "bigint", nullable: false, comment: "指标定义 ID。"), + DimensionKey = table.Column(type: "character varying(256)", maxLength: 256, nullable: false, comment: "维度键(JSON)。"), + WindowStart = table.Column(type: "timestamp with time zone", nullable: false, comment: "统计时间窗口开始。"), + WindowEnd = table.Column(type: "timestamp with time zone", nullable: false, comment: "统计时间窗口结束。"), + Value = table.Column(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false, comment: "数值。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_metric_snapshots", x => x.Id); + }, + comment: "指标快照,用于大盘展示。"); + + migrationBuilder.CreateTable( + name: "navigation_requests", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "bigint", nullable: false, comment: "用户 ID。"), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店 ID。"), + Channel = table.Column(type: "integer", nullable: false, comment: "来源通道(小程序、H5 等)。"), + TargetApp = table.Column(type: "integer", nullable: false, comment: "跳转的地图应用。"), + RequestedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "请求时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_navigation_requests", x => x.Id); + }, + comment: "用户发起的导航请求日志。"); + + migrationBuilder.CreateTable( + name: "order_status_histories", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + OrderId = table.Column(type: "bigint", nullable: false, comment: "订单标识。"), + Status = table.Column(type: "integer", nullable: false, comment: "变更后的状态。"), + OperatorId = table.Column(type: "bigint", nullable: true, comment: "操作人标识(可为空表示系统)。"), + Notes = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "备注信息。"), + OccurredAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "发生时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_order_status_histories", x => x.Id); + }, + comment: "订单状态流转记录。"); + + migrationBuilder.CreateTable( + name: "orders", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + OrderNo = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "订单号。"), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店。"), + Channel = table.Column(type: "integer", nullable: false, comment: "下单渠道。"), + DeliveryType = table.Column(type: "integer", nullable: false, comment: "履约类型。"), + Status = table.Column(type: "integer", nullable: false, comment: "当前状态。"), + PaymentStatus = table.Column(type: "integer", nullable: false, comment: "支付状态。"), + CustomerName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "顾客姓名。"), + CustomerPhone = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "顾客手机号。"), + TableNo = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "就餐桌号。"), + QueueNumber = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "排队号(如有)。"), + ReservationId = table.Column(type: "bigint", nullable: true, comment: "预约 ID。"), + ItemsAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "商品总额。"), + DiscountAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "优惠金额。"), + PayableAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "应付金额。"), + PaidAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "实付金额。"), + PaidAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "支付时间。"), + FinishedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "完成时间。"), + CancelledAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "取消时间。"), + CancelReason = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "取消原因。"), + Remark = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "备注。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_orders", x => x.Id); + }, + comment: "交易订单。"); + + migrationBuilder.CreateTable( + name: "payment_records", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + OrderId = table.Column(type: "bigint", nullable: false, comment: "关联订单。"), + Method = table.Column(type: "integer", nullable: false, comment: "支付方式。"), + Status = table.Column(type: "integer", nullable: false, comment: "支付状态。"), + Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "支付金额。"), + TradeNo = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "平台交易号。"), + ChannelTransactionId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "第三方渠道单号。"), + PaidAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "支付完成时间。"), + Remark = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "错误/备注。"), + Payload = table.Column(type: "text", nullable: true, comment: "原始回调内容。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_payment_records", x => x.Id); + }, + comment: "支付流水。"); + + migrationBuilder.CreateTable( + name: "payment_refund_records", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PaymentRecordId = table.Column(type: "bigint", nullable: false, comment: "原支付记录标识。"), + OrderId = table.Column(type: "bigint", nullable: false, comment: "关联订单标识。"), + Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "退款金额。"), + ChannelRefundId = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "渠道退款流水号。"), + Status = table.Column(type: "integer", nullable: false, comment: "退款状态。"), + RequestedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "退款请求时间。"), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "完成时间。"), + Payload = table.Column(type: "text", nullable: true, comment: "渠道返回的原始数据 JSON。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_payment_refund_records", x => x.Id); + }, + comment: "支付渠道退款流水。"); + + migrationBuilder.CreateTable( + name: "product_addon_groups", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ProductId = table.Column(type: "bigint", nullable: false, comment: "所属商品。"), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "分组名称。"), + SelectionType = table.Column(type: "integer", nullable: false, comment: "选择类型。"), + MinSelect = table.Column(type: "integer", nullable: true, comment: "最小选择数量。"), + MaxSelect = table.Column(type: "integer", nullable: true, comment: "最大选择数量。"), + IsRequired = table.Column(type: "boolean", nullable: false, comment: "是否必选。"), + SortOrder = table.Column(type: "integer", nullable: false, comment: "排序值。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_product_addon_groups", x => x.Id); + }, + comment: "加料/做法分组。"); + + migrationBuilder.CreateTable( + name: "product_addon_options", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AddonGroupId = table.Column(type: "bigint", nullable: false, comment: "所属加料分组。"), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "选项名称。"), + ExtraPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "附加价格。"), + IsDefault = table.Column(type: "boolean", nullable: false, comment: "是否默认选项。"), + SortOrder = table.Column(type: "integer", nullable: false, comment: "排序。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_product_addon_options", x => x.Id); + }, + comment: "加料选项。"); + + migrationBuilder.CreateTable( + name: "product_attribute_groups", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: true, comment: "关联门店,可为空表示所有门店共享。"), + ProductId = table.Column(type: "bigint", nullable: false, comment: "所属商品标识。"), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "分组名称,例如“辣度”“份量”。"), + SelectionType = table.Column(type: "integer", nullable: false, comment: "选择类型(单选/多选)。"), + IsRequired = table.Column(type: "boolean", nullable: false, comment: "是否必选。"), + SortOrder = table.Column(type: "integer", nullable: false, comment: "显示排序。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_product_attribute_groups", x => x.Id); + }, + comment: "商品规格/属性分组。"); + + migrationBuilder.CreateTable( + name: "product_attribute_options", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AttributeGroupId = table.Column(type: "bigint", nullable: false, comment: "所属规格组。"), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "选项名称。"), + ExtraPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "附加价格。"), + SortOrder = table.Column(type: "integer", nullable: false, comment: "排序。"), + IsDefault = table.Column(type: "boolean", nullable: false, comment: "是否默认选中。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_product_attribute_options", x => x.Id); + }, + comment: "商品规格选项。"); + + migrationBuilder.CreateTable( + name: "product_categories", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "所属门店。"), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "分类名称。"), + Description = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "分类描述。"), + SortOrder = table.Column(type: "integer", nullable: false, comment: "排序值。"), + IsEnabled = table.Column(type: "boolean", nullable: false, comment: "是否启用。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_product_categories", x => x.Id); + }, + comment: "商品分类。"); + + migrationBuilder.CreateTable( + name: "product_media_assets", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ProductId = table.Column(type: "bigint", nullable: false, comment: "商品标识。"), + MediaType = table.Column(type: "integer", nullable: false, comment: "媒体类型。"), + Url = table.Column(type: "character varying(512)", maxLength: 512, nullable: false, comment: "媒资链接。"), + Caption = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "描述或标题。"), + SortOrder = table.Column(type: "integer", nullable: false, comment: "排序。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_product_media_assets", x => x.Id); + }, + comment: "商品媒资素材。"); + + migrationBuilder.CreateTable( + name: "product_pricing_rules", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ProductId = table.Column(type: "bigint", nullable: false, comment: "所属商品。"), + RuleType = table.Column(type: "integer", nullable: false, comment: "策略类型。"), + ConditionsJson = table.Column(type: "text", nullable: false, comment: "条件描述(JSON),如会员等级、渠道等。"), + Price = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "特殊价格。"), + StartTime = table.Column(type: "timestamp with time zone", nullable: true, comment: "生效开始时间。"), + EndTime = table.Column(type: "timestamp with time zone", nullable: true, comment: "生效结束时间。"), + WeekdaysJson = table.Column(type: "text", nullable: true, comment: "生效星期(JSON 数组)。"), + SortOrder = table.Column(type: "integer", nullable: false, comment: "排序值。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_product_pricing_rules", x => x.Id); + }, + comment: "商品价格策略,支持会员价/时段价等。"); + + migrationBuilder.CreateTable( + name: "product_skus", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ProductId = table.Column(type: "bigint", nullable: false, comment: "所属商品标识。"), + SkuCode = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "SKU 编码。"), + Barcode = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "条形码。"), + Price = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "售价。"), + OriginalPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "原价。"), + StockQuantity = table.Column(type: "integer", nullable: true, comment: "可售库存。"), + Weight = table.Column(type: "numeric(10,3)", precision: 10, scale: 3, nullable: true, comment: "重量(千克)。"), + AttributesJson = table.Column(type: "text", nullable: false, comment: "规格属性 JSON(记录选项 ID)。"), + SortOrder = table.Column(type: "integer", nullable: false, comment: "排序值。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_product_skus", x => x.Id); + }, + comment: "商品 SKU,记录具体规格组合价格。"); + + migrationBuilder.CreateTable( + name: "products", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "所属门店。"), + CategoryId = table.Column(type: "bigint", nullable: false, comment: "所属分类。"), + SpuCode = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "商品编码。"), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "商品名称。"), + Subtitle = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "副标题/卖点。"), + Unit = table.Column(type: "character varying(16)", maxLength: 16, nullable: true, comment: "售卖单位(份/杯等)。"), + Price = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "现价。"), + OriginalPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "原价。"), + StockQuantity = table.Column(type: "integer", nullable: true, comment: "库存数量(可选)。"), + MaxQuantityPerOrder = table.Column(type: "integer", nullable: true, comment: "最大每单限购。"), + Status = table.Column(type: "integer", nullable: false, comment: "商品状态。"), + CoverImage = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "主图。"), + GalleryImages = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true, comment: "Gallery 图片逗号分隔。"), + Description = table.Column(type: "text", nullable: true, comment: "商品描述。"), + EnableDineIn = table.Column(type: "boolean", nullable: false, comment: "支持堂食。"), + EnablePickup = table.Column(type: "boolean", nullable: false, comment: "支持自提。"), + EnableDelivery = table.Column(type: "boolean", nullable: false, comment: "支持配送。"), + IsFeatured = table.Column(type: "boolean", nullable: false, comment: "是否热门推荐。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_products", x => x.Id); + }, + comment: "商品(SPU)信息。"); + + migrationBuilder.CreateTable( + name: "promotion_campaigns", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "活动名称。"), + PromotionType = table.Column(type: "integer", nullable: false, comment: "活动类型。"), + Status = table.Column(type: "integer", nullable: false, comment: "活动状态。"), + StartAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "开始时间。"), + EndAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "结束时间。"), + Budget = table.Column(type: "numeric", nullable: true, comment: "预算金额。"), + RulesJson = table.Column(type: "text", nullable: false, comment: "活动规则 JSON。"), + AudienceDescription = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "目标人群描述。"), + BannerUrl = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "营销素材(如 banner)。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_promotion_campaigns", x => x.Id); + }, + comment: "营销活动配置。"); + + migrationBuilder.CreateTable( + name: "queue_tickets", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false), + TicketNumber = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "排队编号。"), + PartySize = table.Column(type: "integer", nullable: false, comment: "就餐人数。"), + Status = table.Column(type: "integer", nullable: false, comment: "状态。"), + EstimatedWaitMinutes = table.Column(type: "integer", nullable: true, comment: "预计等待分钟。"), + CalledAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "叫号时间。"), + ExpiredAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "过号时间。"), + CancelledAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "取消时间。"), + Remark = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "备注。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_queue_tickets", x => x.Id); + }, + comment: "排队叫号。"); + + migrationBuilder.CreateTable( + name: "refund_requests", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + OrderId = table.Column(type: "bigint", nullable: false, comment: "关联订单标识。"), + RefundNo = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "退款单号。"), + Amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "申请金额。"), + Reason = table.Column(type: "character varying(256)", maxLength: 256, nullable: false, comment: "申请原因。"), + Status = table.Column(type: "integer", nullable: false, comment: "退款状态。"), + RequestedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "用户提交时间。"), + ProcessedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "审核完成时间。"), + ReviewNotes = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "审核备注。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_refund_requests", x => x.Id); + }, + comment: "售后/退款申请。"); + + migrationBuilder.CreateTable( + name: "reservations", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店。"), + ReservationNo = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "预约号。"), + CustomerName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "客户姓名。"), + CustomerPhone = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "联系电话。"), + PeopleCount = table.Column(type: "integer", nullable: false, comment: "用餐人数。"), + ReservationTime = table.Column(type: "timestamp with time zone", nullable: false, comment: "预约时间(UTC)。"), + Status = table.Column(type: "integer", nullable: false, comment: "状态。"), + TablePreference = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "桌型/标签。"), + Remark = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "备注。"), + CheckInCode = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "核销码/到店码。"), + CheckedInAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "实际签到时间。"), + CancelledAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "取消时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_reservations", x => x.Id); + }, + comment: "预约/预订记录。"); + + migrationBuilder.CreateTable( + name: "shopping_carts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "bigint", nullable: false, comment: "用户标识。"), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + Status = table.Column(type: "integer", nullable: false, comment: "购物车状态,包含正常/锁定。"), + TableContext = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "桌码或场景标识(扫码点餐)。"), + DeliveryPreference = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "履约方式(堂食/自提/配送)缓存。"), + LastModifiedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "最近一次修改时间(UTC)。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_shopping_carts", x => x.Id); + }, + comment: "用户购物车,按租户/门店隔离。"); + + migrationBuilder.CreateTable( + name: "store_business_hours", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + DayOfWeek = table.Column(type: "integer", nullable: false, comment: "星期几,0 表示周日。"), + HourType = table.Column(type: "integer", nullable: false, comment: "时段类型(正常营业、休息、预约等)。"), + StartTime = table.Column(type: "interval", nullable: false, comment: "开始时间(本地时间)。"), + EndTime = table.Column(type: "interval", nullable: false, comment: "结束时间(本地时间)。"), + CapacityLimit = table.Column(type: "integer", nullable: true, comment: "最大接待容量或单量限制。"), + Notes = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "备注。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_store_business_hours", x => x.Id); + }, + comment: "门店营业时段配置。"); + + migrationBuilder.CreateTable( + name: "store_delivery_zones", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + ZoneName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "区域名称。"), + PolygonGeoJson = table.Column(type: "text", nullable: false, comment: "GeoJSON 表示的多边形范围。"), + MinimumOrderAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "起送价。"), + DeliveryFee = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true, comment: "配送费。"), + EstimatedMinutes = table.Column(type: "integer", nullable: true, comment: "预计送达分钟。"), + SortOrder = table.Column(type: "integer", nullable: false, comment: "排序值。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_store_delivery_zones", x => x.Id); + }, + comment: "门店配送范围配置。"); + + migrationBuilder.CreateTable( + name: "store_employee_shifts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + StaffId = table.Column(type: "bigint", nullable: false, comment: "员工标识。"), + ShiftDate = table.Column(type: "timestamp with time zone", nullable: false, comment: "班次日期。"), + StartTime = table.Column(type: "interval", nullable: false, comment: "开始时间。"), + EndTime = table.Column(type: "interval", nullable: false, comment: "结束时间。"), + RoleType = table.Column(type: "integer", nullable: false, comment: "排班角色。"), + Notes = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "备注。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_store_employee_shifts", x => x.Id); + }, + comment: "门店员工排班记录。"); + + migrationBuilder.CreateTable( + name: "store_holidays", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + Date = table.Column(type: "timestamp with time zone", nullable: false, comment: "日期。"), + IsClosed = table.Column(type: "boolean", nullable: false, comment: "是否全天闭店。"), + Reason = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "说明内容。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_store_holidays", x => x.Id); + }, + comment: "门店休息日或特殊营业日。"); + + migrationBuilder.CreateTable( + name: "store_table_areas", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "区域名称。"), + Description = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "区域描述。"), + SortOrder = table.Column(type: "integer", nullable: false, comment: "排序值。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_store_table_areas", x => x.Id); + }, + comment: "门店桌台区域配置。"); + + migrationBuilder.CreateTable( + name: "store_tables", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StoreId = table.Column(type: "bigint", nullable: false, comment: "门店标识。"), + AreaId = table.Column(type: "bigint", nullable: true, comment: "所在区域 ID。"), + TableCode = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "桌码。"), + Capacity = table.Column(type: "integer", nullable: false, comment: "可容纳人数。"), + Tags = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "桌台标签(堂食、快餐等)。"), + Status = table.Column(type: "integer", nullable: false, comment: "当前桌台状态。"), + QrCodeUrl = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "桌码二维码地址。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_store_tables", x => x.Id); + }, + comment: "桌台信息与二维码绑定。"); + + migrationBuilder.CreateTable( + name: "stores", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MerchantId = table.Column(type: "bigint", nullable: false, comment: "所属商户标识。"), + Code = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "门店编码,便于扫码及外部对接。"), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "门店名称。"), + Phone = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "联系电话。"), + ManagerName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "门店负责人姓名。"), + Status = table.Column(type: "integer", nullable: false, comment: "门店当前运营状态。"), + Country = table.Column(type: "text", nullable: true, comment: "所在国家或地区。"), + Province = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "所在省份。"), + City = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "所在城市。"), + District = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "区县信息。"), + Address = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "详细地址。"), + Longitude = table.Column(type: "double precision", nullable: true, comment: "高德/腾讯地图经度。"), + Latitude = table.Column(type: "double precision", nullable: true, comment: "纬度。"), + Description = table.Column(type: "text", nullable: true, comment: "门店描述或公告。"), + BusinessHours = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "门店营业时段描述(备用字符串)。"), + SupportsDineIn = table.Column(type: "boolean", nullable: false, comment: "是否支持堂食。"), + SupportsPickup = table.Column(type: "boolean", nullable: false, comment: "是否支持自提。"), + SupportsDelivery = table.Column(type: "boolean", nullable: false, comment: "是否支持配送。"), + SupportsReservation = table.Column(type: "boolean", nullable: false, comment: "支持预约。"), + SupportsQueueing = table.Column(type: "boolean", nullable: false, comment: "支持排队叫号。"), + DeliveryRadiusKm = table.Column(type: "numeric(6,2)", precision: 6, scale: 2, nullable: false, comment: "默认配送半径(公里)。"), + Announcement = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "门店公告。"), + Tags = table.Column(type: "text", nullable: true, comment: "门店标签(逗号分隔)。"), + CoverImageUrl = table.Column(type: "text", nullable: true, comment: "门店海报。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_stores", x => x.Id); + }, + comment: "门店信息,承载营业配置与能力。"); + + migrationBuilder.CreateTable( + name: "support_tickets", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TicketNo = table.Column(type: "character varying(32)", maxLength: 32, nullable: false, comment: "工单编号。"), + CustomerUserId = table.Column(type: "bigint", nullable: false, comment: "客户用户 ID。"), + OrderId = table.Column(type: "bigint", nullable: true, comment: "关联订单(如有)。"), + Subject = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "工单主题。"), + Description = table.Column(type: "text", nullable: false, comment: "工单详情。"), + Priority = table.Column(type: "integer", nullable: false, comment: "优先级。"), + Status = table.Column(type: "integer", nullable: false, comment: "状态。"), + AssignedAgentId = table.Column(type: "bigint", nullable: true, comment: "指派的客服。"), + ClosedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "关闭时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_support_tickets", x => x.Id); + }, + comment: "客服工单。"); + + migrationBuilder.CreateTable( + name: "tenant_billing_statements", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + StatementNo = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "账单编号,供对账查询。"), + PeriodStart = table.Column(type: "timestamp with time zone", nullable: false, comment: "账单周期开始时间。"), + PeriodEnd = table.Column(type: "timestamp with time zone", nullable: false, comment: "账单周期结束时间。"), + AmountDue = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "应付金额。"), + AmountPaid = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "实付金额。"), + Status = table.Column(type: "integer", nullable: false, comment: "当前付款状态。"), + DueDate = table.Column(type: "timestamp with time zone", nullable: false, comment: "到期日。"), + LineItemsJson = table.Column(type: "text", nullable: true, comment: "账单明细 JSON,记录各项费用。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_tenant_billing_statements", x => x.Id); + }, + comment: "租户账单,用于呈现周期性收费。"); + + migrationBuilder.CreateTable( + name: "tenant_notifications", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Title = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "通知标题。"), + Message = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false, comment: "通知正文。"), + Channel = table.Column(type: "integer", nullable: false, comment: "发布通道(站内、邮件、短信等)。"), + Severity = table.Column(type: "integer", nullable: false, comment: "通知重要级别。"), + SentAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "推送时间。"), + ReadAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "租户是否已阅读。"), + MetadataJson = table.Column(type: "text", nullable: true, comment: "附加元数据 JSON。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_tenant_notifications", x => x.Id); + }, + comment: "面向租户的站内通知或消息推送。"); + + migrationBuilder.CreateTable( + name: "tenant_packages", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "套餐名称,展示给租户的简称。"), + Description = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "套餐描述,包含适用场景、权益等。"), + PackageType = table.Column(type: "integer", nullable: false, comment: "套餐分类(试用、标准、旗舰等)。"), + MonthlyPrice = table.Column(type: "numeric", nullable: true, comment: "月付价格,单位:人民币元。"), + YearlyPrice = table.Column(type: "numeric", nullable: true, comment: "年付价格,单位:人民币元。"), + MaxStoreCount = table.Column(type: "integer", nullable: true, comment: "允许的最大门店数。"), + MaxAccountCount = table.Column(type: "integer", nullable: true, comment: "允许创建的最大账号数。"), + MaxStorageGb = table.Column(type: "integer", nullable: true, comment: "存储容量上限(GB)。"), + MaxSmsCredits = table.Column(type: "integer", nullable: true, comment: "每月短信额度上限。"), + MaxDeliveryOrders = table.Column(type: "integer", nullable: true, comment: "每月可调用的配送单数量上限。"), + FeaturePoliciesJson = table.Column(type: "text", nullable: true, comment: "权益明细 JSON,记录自定义特性开关。"), + IsActive = table.Column(type: "boolean", nullable: false, comment: "是否仍可售卖。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。") + }, + constraints: table => + { + table.PrimaryKey("PK_tenant_packages", x => x.Id); + }, + comment: "平台提供的租户套餐定义。"); + + migrationBuilder.CreateTable( + name: "tenant_quota_usages", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + QuotaType = table.Column(type: "integer", nullable: false, comment: "配额类型,例如门店数、短信条数等。"), + LimitValue = table.Column(type: "numeric", nullable: false, comment: "当前配额上限。"), + UsedValue = table.Column(type: "numeric", nullable: false, comment: "已消耗的数量。"), + ResetCycle = table.Column(type: "text", nullable: true, comment: "配额刷新周期描述(如月、年)。"), + LastResetAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次重置时间。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_tenant_quota_usages", x => x.Id); + }, + comment: "租户配额使用情况快照。"); + + migrationBuilder.CreateTable( + name: "tenant_subscriptions", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TenantPackageId = table.Column(type: "bigint", nullable: false, comment: "当前订阅关联的套餐标识。"), + EffectiveFrom = table.Column(type: "timestamp with time zone", nullable: false, comment: "订阅生效时间(UTC)。"), + EffectiveTo = table.Column(type: "timestamp with time zone", nullable: false, comment: "订阅到期时间(UTC)。"), + NextBillingDate = table.Column(type: "timestamp with time zone", nullable: true, comment: "下一个计费时间,配合自动续费使用。"), + Status = table.Column(type: "integer", nullable: false, comment: "订阅当前状态。"), + AutoRenew = table.Column(type: "boolean", nullable: false, comment: "是否开启自动续费。"), + ScheduledPackageId = table.Column(type: "bigint", nullable: true, comment: "若已排期升降配,对应的新套餐 ID。"), + Notes = table.Column(type: "text", nullable: true, comment: "运营备注信息。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_tenant_subscriptions", x => x.Id); + }, + comment: "租户套餐订阅记录。"); + + migrationBuilder.CreateTable( + name: "tenants", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "租户短编码,作为跨系统引用的唯一标识。"), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "租户全称或品牌名称。"), + ShortName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "对外展示的简称。"), + LegalEntityName = table.Column(type: "text", nullable: true, comment: "法人或公司主体名称。"), + Industry = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "所属行业,如餐饮、零售等。"), + LogoUrl = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "LOGO 图片地址。"), + CoverImageUrl = table.Column(type: "text", nullable: true, comment: "品牌海报或封面图。"), + Website = table.Column(type: "text", nullable: true, comment: "官网或主要宣传链接。"), + Country = table.Column(type: "text", nullable: true, comment: "所在国家/地区。"), + Province = table.Column(type: "text", nullable: true, comment: "所在省份或州。"), + City = table.Column(type: "text", nullable: true, comment: "所在城市。"), + Address = table.Column(type: "text", nullable: true, comment: "详细地址信息。"), + ContactName = table.Column(type: "character varying(64)", maxLength: 64, nullable: true, comment: "主联系人姓名。"), + ContactPhone = table.Column(type: "character varying(32)", maxLength: 32, nullable: true, comment: "主联系人电话。"), + ContactEmail = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "主联系人邮箱。"), + PrimaryOwnerUserId = table.Column(type: "bigint", nullable: true, comment: "系统内对应的租户所有者账号 ID。"), + Status = table.Column(type: "integer", nullable: false, comment: "租户当前状态,涵盖审核、启用、停用等场景。"), + EffectiveFrom = table.Column(type: "timestamp with time zone", nullable: true, comment: "服务生效时间(UTC)。"), + EffectiveTo = table.Column(type: "timestamp with time zone", nullable: true, comment: "服务到期时间(UTC)。"), + SuspendedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次暂停服务时间。"), + SuspensionReason = table.Column(type: "text", nullable: true, comment: "暂停或终止的原因说明。"), + Tags = table.Column(type: "text", nullable: true, comment: "业务标签集合(逗号分隔)。"), + Remarks = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "备注信息,用于运营记录特殊说明。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。") + }, + constraints: table => + { + table.PrimaryKey("PK_tenants", x => x.Id); + }, + comment: "平台租户信息,描述租户的生命周期与基础资料。"); + + migrationBuilder.CreateTable( + name: "ticket_comments", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SupportTicketId = table.Column(type: "bigint", nullable: false, comment: "工单标识。"), + AuthorUserId = table.Column(type: "bigint", nullable: true, comment: "评论人 ID。"), + Content = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false, comment: "评论内容。"), + IsInternal = table.Column(type: "boolean", nullable: false, comment: "是否内部备注。"), + AttachmentsJson = table.Column(type: "text", nullable: true, comment: "附件 JSON。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_ticket_comments", x => x.Id); + }, + comment: "工单评论/流转记录。"); + + migrationBuilder.CreateTable( + name: "order_items", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + OrderId = table.Column(type: "bigint", nullable: false, comment: "订单 ID。"), + ProductId = table.Column(type: "bigint", nullable: false, comment: "商品 ID。"), + ProductName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "商品名称。"), + SkuName = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "SKU/规格描述。"), + Unit = table.Column(type: "character varying(16)", maxLength: 16, nullable: true, comment: "单位。"), + Quantity = table.Column(type: "integer", nullable: false, comment: "数量。"), + UnitPrice = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "单价。"), + DiscountAmount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "折扣金额。"), + SubTotal = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false, comment: "小计。"), + AttributesJson = table.Column(type: "text", nullable: true, comment: "自定义属性 JSON。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_order_items", x => x.Id); + table.ForeignKey( + name: "FK_order_items_orders_OrderId", + column: x => x.OrderId, + principalTable: "orders", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }, + comment: "订单明细。"); + + migrationBuilder.CreateIndex( + name: "IX_affiliate_orders_TenantId_AffiliatePartnerId_OrderId", + table: "affiliate_orders", + columns: new[] { "TenantId", "AffiliatePartnerId", "OrderId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_affiliate_partners_TenantId_DisplayName", + table: "affiliate_partners", + columns: new[] { "TenantId", "DisplayName" }); + + migrationBuilder.CreateIndex( + name: "IX_affiliate_payouts_TenantId_AffiliatePartnerId_Period", + table: "affiliate_payouts", + columns: new[] { "TenantId", "AffiliatePartnerId", "Period" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_cart_items_TenantId_ShoppingCartId", + table: "cart_items", + columns: new[] { "TenantId", "ShoppingCartId" }); + + migrationBuilder.CreateIndex( + name: "IX_chat_messages_TenantId_ChatSessionId_CreatedAt", + table: "chat_messages", + columns: new[] { "TenantId", "ChatSessionId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_chat_sessions_TenantId_SessionCode", + table: "chat_sessions", + columns: new[] { "TenantId", "SessionCode" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_checkin_campaigns_TenantId_Name", + table: "checkin_campaigns", + columns: new[] { "TenantId", "Name" }); + + migrationBuilder.CreateIndex( + name: "IX_checkin_records_TenantId_CheckInCampaignId_UserId_CheckInDa~", + table: "checkin_records", + columns: new[] { "TenantId", "CheckInCampaignId", "UserId", "CheckInDate" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_checkout_sessions_TenantId_SessionToken", + table: "checkout_sessions", + columns: new[] { "TenantId", "SessionToken" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_community_comments_TenantId_PostId_CreatedAt", + table: "community_comments", + columns: new[] { "TenantId", "PostId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_community_posts_TenantId_AuthorUserId_CreatedAt", + table: "community_posts", + columns: new[] { "TenantId", "AuthorUserId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_community_reactions_TenantId_PostId_UserId", + table: "community_reactions", + columns: new[] { "TenantId", "PostId", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_coupons_TenantId_Code", + table: "coupons", + columns: new[] { "TenantId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_delivery_events_TenantId_DeliveryOrderId_EventType", + table: "delivery_events", + columns: new[] { "TenantId", "DeliveryOrderId", "EventType" }); + + migrationBuilder.CreateIndex( + name: "IX_delivery_orders_TenantId_OrderId", + table: "delivery_orders", + columns: new[] { "TenantId", "OrderId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_group_orders_TenantId_GroupOrderNo", + table: "group_orders", + columns: new[] { "TenantId", "GroupOrderNo" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_group_participants_TenantId_GroupOrderId_UserId", + table: "group_participants", + columns: new[] { "TenantId", "GroupOrderId", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_inventory_adjustments_TenantId_InventoryItemId_OccurredAt", + table: "inventory_adjustments", + columns: new[] { "TenantId", "InventoryItemId", "OccurredAt" }); + + migrationBuilder.CreateIndex( + name: "IX_inventory_batches_TenantId_StoreId_ProductSkuId_BatchNumber", + table: "inventory_batches", + columns: new[] { "TenantId", "StoreId", "ProductSkuId", "BatchNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_inventory_items_TenantId_StoreId_ProductSkuId_BatchNumber", + table: "inventory_items", + columns: new[] { "TenantId", "StoreId", "ProductSkuId", "BatchNumber" }); + + migrationBuilder.CreateIndex( + name: "IX_map_locations_TenantId_StoreId", + table: "map_locations", + columns: new[] { "TenantId", "StoreId" }); + + migrationBuilder.CreateIndex( + name: "IX_member_growth_logs_TenantId_MemberId_OccurredAt", + table: "member_growth_logs", + columns: new[] { "TenantId", "MemberId", "OccurredAt" }); + + migrationBuilder.CreateIndex( + name: "IX_member_point_ledgers_TenantId_MemberId_OccurredAt", + table: "member_point_ledgers", + columns: new[] { "TenantId", "MemberId", "OccurredAt" }); + + migrationBuilder.CreateIndex( + name: "IX_member_profiles_TenantId_Mobile", + table: "member_profiles", + columns: new[] { "TenantId", "Mobile" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_member_tiers_TenantId_Name", + table: "member_tiers", + columns: new[] { "TenantId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_merchant_contracts_TenantId_MerchantId_ContractNumber", + table: "merchant_contracts", + columns: new[] { "TenantId", "MerchantId", "ContractNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_merchant_documents_TenantId_MerchantId_DocumentType", + table: "merchant_documents", + columns: new[] { "TenantId", "MerchantId", "DocumentType" }); + + migrationBuilder.CreateIndex( + name: "IX_merchant_staff_TenantId_MerchantId_Phone", + table: "merchant_staff", + columns: new[] { "TenantId", "MerchantId", "Phone" }); + + migrationBuilder.CreateIndex( + name: "IX_merchants_TenantId", + table: "merchants", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_metric_alert_rules_TenantId_MetricDefinitionId_Severity", + table: "metric_alert_rules", + columns: new[] { "TenantId", "MetricDefinitionId", "Severity" }); + + migrationBuilder.CreateIndex( + name: "IX_metric_definitions_TenantId_Code", + table: "metric_definitions", + columns: new[] { "TenantId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_metric_snapshots_TenantId_MetricDefinitionId_DimensionKey_W~", + table: "metric_snapshots", + columns: new[] { "TenantId", "MetricDefinitionId", "DimensionKey", "WindowStart", "WindowEnd" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_navigation_requests_TenantId_UserId_StoreId_RequestedAt", + table: "navigation_requests", + columns: new[] { "TenantId", "UserId", "StoreId", "RequestedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_order_items_OrderId", + table: "order_items", + column: "OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_order_items_TenantId_OrderId", + table: "order_items", + columns: new[] { "TenantId", "OrderId" }); + + migrationBuilder.CreateIndex( + name: "IX_order_status_histories_TenantId_OrderId_OccurredAt", + table: "order_status_histories", + columns: new[] { "TenantId", "OrderId", "OccurredAt" }); + + migrationBuilder.CreateIndex( + name: "IX_orders_TenantId_OrderNo", + table: "orders", + columns: new[] { "TenantId", "OrderNo" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_orders_TenantId_StoreId_Status", + table: "orders", + columns: new[] { "TenantId", "StoreId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_payment_records_TenantId_OrderId", + table: "payment_records", + columns: new[] { "TenantId", "OrderId" }); + + migrationBuilder.CreateIndex( + name: "IX_payment_refund_records_TenantId_PaymentRecordId", + table: "payment_refund_records", + columns: new[] { "TenantId", "PaymentRecordId" }); + + migrationBuilder.CreateIndex( + name: "IX_product_addon_groups_TenantId_ProductId_Name", + table: "product_addon_groups", + columns: new[] { "TenantId", "ProductId", "Name" }); + + migrationBuilder.CreateIndex( + name: "IX_product_attribute_groups_TenantId_StoreId_Name", + table: "product_attribute_groups", + columns: new[] { "TenantId", "StoreId", "Name" }); + + migrationBuilder.CreateIndex( + name: "IX_product_attribute_options_TenantId_AttributeGroupId_Name", + table: "product_attribute_options", + columns: new[] { "TenantId", "AttributeGroupId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_product_categories_TenantId_StoreId", + table: "product_categories", + columns: new[] { "TenantId", "StoreId" }); + + migrationBuilder.CreateIndex( + name: "IX_product_pricing_rules_TenantId_ProductId_RuleType", + table: "product_pricing_rules", + columns: new[] { "TenantId", "ProductId", "RuleType" }); + + migrationBuilder.CreateIndex( + name: "IX_product_skus_TenantId_SkuCode", + table: "product_skus", + columns: new[] { "TenantId", "SkuCode" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_products_TenantId_SpuCode", + table: "products", + columns: new[] { "TenantId", "SpuCode" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_products_TenantId_StoreId", + table: "products", + columns: new[] { "TenantId", "StoreId" }); + + migrationBuilder.CreateIndex( + name: "IX_queue_tickets_TenantId_StoreId", + table: "queue_tickets", + columns: new[] { "TenantId", "StoreId" }); + + migrationBuilder.CreateIndex( + name: "IX_queue_tickets_TenantId_StoreId_TicketNumber", + table: "queue_tickets", + columns: new[] { "TenantId", "StoreId", "TicketNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_refund_requests_TenantId_RefundNo", + table: "refund_requests", + columns: new[] { "TenantId", "RefundNo" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_reservations_TenantId_ReservationNo", + table: "reservations", + columns: new[] { "TenantId", "ReservationNo" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_reservations_TenantId_StoreId", + table: "reservations", + columns: new[] { "TenantId", "StoreId" }); + + migrationBuilder.CreateIndex( + name: "IX_shopping_carts_TenantId_UserId_StoreId", + table: "shopping_carts", + columns: new[] { "TenantId", "UserId", "StoreId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_store_business_hours_TenantId_StoreId_DayOfWeek", + table: "store_business_hours", + columns: new[] { "TenantId", "StoreId", "DayOfWeek" }); + + migrationBuilder.CreateIndex( + name: "IX_store_delivery_zones_TenantId_StoreId_ZoneName", + table: "store_delivery_zones", + columns: new[] { "TenantId", "StoreId", "ZoneName" }); + + migrationBuilder.CreateIndex( + name: "IX_store_employee_shifts_TenantId_StoreId_ShiftDate_StaffId", + table: "store_employee_shifts", + columns: new[] { "TenantId", "StoreId", "ShiftDate", "StaffId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_store_holidays_TenantId_StoreId_Date", + table: "store_holidays", + columns: new[] { "TenantId", "StoreId", "Date" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_store_table_areas_TenantId_StoreId_Name", + table: "store_table_areas", + columns: new[] { "TenantId", "StoreId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_store_tables_TenantId_StoreId_TableCode", + table: "store_tables", + columns: new[] { "TenantId", "StoreId", "TableCode" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_stores_TenantId_Code", + table: "stores", + columns: new[] { "TenantId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_stores_TenantId_MerchantId", + table: "stores", + columns: new[] { "TenantId", "MerchantId" }); + + migrationBuilder.CreateIndex( + name: "IX_support_tickets_TenantId_TicketNo", + table: "support_tickets", + columns: new[] { "TenantId", "TicketNo" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_tenant_billing_statements_TenantId_StatementNo", + table: "tenant_billing_statements", + columns: new[] { "TenantId", "StatementNo" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_tenant_notifications_TenantId_Channel_SentAt", + table: "tenant_notifications", + columns: new[] { "TenantId", "Channel", "SentAt" }); + + migrationBuilder.CreateIndex( + name: "IX_tenant_quota_usages_TenantId_QuotaType", + table: "tenant_quota_usages", + columns: new[] { "TenantId", "QuotaType" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_tenant_subscriptions_TenantId_TenantPackageId", + table: "tenant_subscriptions", + columns: new[] { "TenantId", "TenantPackageId" }); + + migrationBuilder.CreateIndex( + name: "IX_tenants_Code", + table: "tenants", + column: "Code", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ticket_comments_TenantId_SupportTicketId", + table: "ticket_comments", + columns: new[] { "TenantId", "SupportTicketId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "affiliate_orders"); + + migrationBuilder.DropTable( + name: "affiliate_partners"); + + migrationBuilder.DropTable( + name: "affiliate_payouts"); + + migrationBuilder.DropTable( + name: "cart_item_addons"); + + migrationBuilder.DropTable( + name: "cart_items"); + + migrationBuilder.DropTable( + name: "chat_messages"); + + migrationBuilder.DropTable( + name: "chat_sessions"); + + migrationBuilder.DropTable( + name: "checkin_campaigns"); + + migrationBuilder.DropTable( + name: "checkin_records"); + + migrationBuilder.DropTable( + name: "checkout_sessions"); + + migrationBuilder.DropTable( + name: "community_comments"); + + migrationBuilder.DropTable( + name: "community_posts"); + + migrationBuilder.DropTable( + name: "community_reactions"); + + migrationBuilder.DropTable( + name: "coupon_templates"); + + migrationBuilder.DropTable( + name: "coupons"); + + migrationBuilder.DropTable( + name: "delivery_events"); + + migrationBuilder.DropTable( + name: "delivery_orders"); + + migrationBuilder.DropTable( + name: "group_orders"); + + migrationBuilder.DropTable( + name: "group_participants"); + + migrationBuilder.DropTable( + name: "inventory_adjustments"); + + migrationBuilder.DropTable( + name: "inventory_batches"); + + migrationBuilder.DropTable( + name: "inventory_items"); + + migrationBuilder.DropTable( + name: "map_locations"); + + migrationBuilder.DropTable( + name: "member_growth_logs"); + + migrationBuilder.DropTable( + name: "member_point_ledgers"); + + migrationBuilder.DropTable( + name: "member_profiles"); + + migrationBuilder.DropTable( + name: "member_tiers"); + + migrationBuilder.DropTable( + name: "merchant_contracts"); + + migrationBuilder.DropTable( + name: "merchant_documents"); + + migrationBuilder.DropTable( + name: "merchant_staff"); + + migrationBuilder.DropTable( + name: "merchants"); + + migrationBuilder.DropTable( + name: "metric_alert_rules"); + + migrationBuilder.DropTable( + name: "metric_definitions"); + + migrationBuilder.DropTable( + name: "metric_snapshots"); + + migrationBuilder.DropTable( + name: "navigation_requests"); + + migrationBuilder.DropTable( + name: "order_items"); + + migrationBuilder.DropTable( + name: "order_status_histories"); + + migrationBuilder.DropTable( + name: "payment_records"); + + migrationBuilder.DropTable( + name: "payment_refund_records"); + + migrationBuilder.DropTable( + name: "product_addon_groups"); + + migrationBuilder.DropTable( + name: "product_addon_options"); + + migrationBuilder.DropTable( + name: "product_attribute_groups"); + + migrationBuilder.DropTable( + name: "product_attribute_options"); + + migrationBuilder.DropTable( + name: "product_categories"); + + migrationBuilder.DropTable( + name: "product_media_assets"); + + migrationBuilder.DropTable( + name: "product_pricing_rules"); + + migrationBuilder.DropTable( + name: "product_skus"); + + migrationBuilder.DropTable( + name: "products"); + + migrationBuilder.DropTable( + name: "promotion_campaigns"); + + migrationBuilder.DropTable( + name: "queue_tickets"); + + migrationBuilder.DropTable( + name: "refund_requests"); + + migrationBuilder.DropTable( + name: "reservations"); + + migrationBuilder.DropTable( + name: "shopping_carts"); + + migrationBuilder.DropTable( + name: "store_business_hours"); + + migrationBuilder.DropTable( + name: "store_delivery_zones"); + + migrationBuilder.DropTable( + name: "store_employee_shifts"); + + migrationBuilder.DropTable( + name: "store_holidays"); + + migrationBuilder.DropTable( + name: "store_table_areas"); + + migrationBuilder.DropTable( + name: "store_tables"); + + migrationBuilder.DropTable( + name: "stores"); + + migrationBuilder.DropTable( + name: "support_tickets"); + + migrationBuilder.DropTable( + name: "tenant_billing_statements"); + + migrationBuilder.DropTable( + name: "tenant_notifications"); + + migrationBuilder.DropTable( + name: "tenant_packages"); + + migrationBuilder.DropTable( + name: "tenant_quota_usages"); + + migrationBuilder.DropTable( + name: "tenant_subscriptions"); + + migrationBuilder.DropTable( + name: "tenants"); + + migrationBuilder.DropTable( + name: "ticket_comments"); + + migrationBuilder.DropTable( + name: "orders"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.Designer.cs new file mode 100644 index 0000000..96bfd4a --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.Designer.cs @@ -0,0 +1,210 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb +{ + [DbContext(typeof(DictionaryDbContext))] + [Migration("20251202005247_InitSnowflake_Dictionary")] + partial class InitSnowflake_Dictionary + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("分组编码(唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("分组名称。"); + + b.Property("Scope") + .HasColumnType("integer") + .HasComment("分组作用域:系统/业务。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("dictionary_groups", null, t => + { + t.HasComment("参数字典分组(系统参数、业务参数)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("GroupId") + .HasColumnType("bigint") + .HasComment("关联分组 ID。"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasComment("是否默认项。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("字典项键。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasComment("排序值,越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("字典项值。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("GroupId", "Key") + .IsUnique(); + + b.ToTable("dictionary_items", null, t => + { + t.HasComment("参数字典项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b => + { + b.HasOne("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", "Group") + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.cs new file mode 100644 index 0000000..374be49 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.cs @@ -0,0 +1,106 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb +{ + /// + public partial class InitSnowflake_Dictionary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "dictionary_groups", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "分组编码(唯一)。"), + Name = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "分组名称。"), + Scope = table.Column(type: "integer", nullable: false, comment: "分组作用域:系统/业务。"), + Description = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "描述信息。"), + IsEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true, comment: "是否启用。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_dictionary_groups", x => x.Id); + }, + comment: "参数字典分组(系统参数、业务参数)。"); + + migrationBuilder.CreateTable( + name: "dictionary_items", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + GroupId = table.Column(type: "bigint", nullable: false, comment: "关联分组 ID。"), + Key = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "字典项键。"), + Value = table.Column(type: "character varying(256)", maxLength: 256, nullable: false, comment: "字典项值。"), + IsDefault = table.Column(type: "boolean", nullable: false, comment: "是否默认项。"), + IsEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true, comment: "是否启用。"), + SortOrder = table.Column(type: "integer", nullable: false, defaultValue: 100, comment: "排序值,越小越靠前。"), + Description = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "描述信息。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_dictionary_items", x => x.Id); + table.ForeignKey( + name: "FK_dictionary_items_dictionary_groups_GroupId", + column: x => x.GroupId, + principalTable: "dictionary_groups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }, + comment: "参数字典项。"); + + migrationBuilder.CreateIndex( + name: "IX_dictionary_groups_TenantId", + table: "dictionary_groups", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_dictionary_groups_TenantId_Code", + table: "dictionary_groups", + columns: new[] { "TenantId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_dictionary_items_GroupId_Key", + table: "dictionary_items", + columns: new[] { "GroupId", "Key" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_dictionary_items_TenantId", + table: "dictionary_items", + column: "TenantId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "dictionary_items"); + + migrationBuilder.DropTable( + name: "dictionary_groups"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.Designer.cs new file mode 100644 index 0000000..8d3c8c2 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.Designer.cs @@ -0,0 +1,288 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb +{ + [DbContext(typeof(DictionaryDbContext))] + [Migration("20251202043204_AddSystemParametersTable")] + partial class AddSystemParametersTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("分组编码(唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("分组名称。"); + + b.Property("Scope") + .HasColumnType("integer") + .HasComment("分组作用域:系统/业务。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("dictionary_groups", null, t => + { + t.HasComment("参数字典分组(系统参数、业务参数)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("GroupId") + .HasColumnType("bigint") + .HasComment("关联分组 ID。"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasComment("是否默认项。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("字典项键。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasComment("排序值,越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("字典项值。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("GroupId", "Key") + .IsUnique(); + + b.ToTable("dictionary_items", null, t => + { + t.HasComment("参数字典项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.SystemParameters.Entities.SystemParameter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("参数键,租户内唯一。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasComment("排序值,越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasComment("参数值,支持文本或 JSON。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Key") + .IsUnique(); + + b.ToTable("system_parameters", null, t => + { + t.HasComment("系统参数实体:支持按租户维护的键值型配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b => + { + b.HasOne("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", "Group") + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.cs new file mode 100644 index 0000000..816fe03 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb +{ + /// + public partial class AddSystemParametersTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "system_parameters", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Key = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "参数键,租户内唯一。"), + Value = table.Column(type: "text", nullable: false, comment: "参数值,支持文本或 JSON。"), + Description = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "描述信息。"), + SortOrder = table.Column(type: "integer", nullable: false, defaultValue: 100, comment: "排序值,越小越靠前。"), + IsEnabled = table.Column(type: "boolean", nullable: false, defaultValue: true, comment: "是否启用。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_system_parameters", x => x.Id); + }, + comment: "系统参数实体:支持按租户维护的键值型配置。"); + + migrationBuilder.CreateIndex( + name: "IX_system_parameters_TenantId", + table: "system_parameters", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_system_parameters_TenantId_Key", + table: "system_parameters", + columns: new[] { "TenantId", "Key" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "system_parameters"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/DictionaryDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/DictionaryDbContextModelSnapshot.cs new file mode 100644 index 0000000..35585f3 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/DictionaryDbContextModelSnapshot.cs @@ -0,0 +1,285 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb +{ + [DbContext(typeof(DictionaryDbContext))] + partial class DictionaryDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("分组编码(唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("分组名称。"); + + b.Property("Scope") + .HasColumnType("integer") + .HasComment("分组作用域:系统/业务。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("dictionary_groups", null, t => + { + t.HasComment("参数字典分组(系统参数、业务参数)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("GroupId") + .HasColumnType("bigint") + .HasComment("关联分组 ID。"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasComment("是否默认项。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("字典项键。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasComment("排序值,越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("字典项值。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("GroupId", "Key") + .IsUnique(); + + b.ToTable("dictionary_items", null, t => + { + t.HasComment("参数字典项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.SystemParameters.Entities.SystemParameter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("参数键,租户内唯一。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasComment("排序值,越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasComment("参数值,支持文本或 JSON。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Key") + .IsUnique(); + + b.ToTable("system_parameters", null, t => + { + t.HasComment("系统参数实体:支持按租户维护的键值型配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b => + { + b.HasOne("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", "Group") + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.Designer.cs new file mode 100644 index 0000000..87e37a5 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.Designer.cs @@ -0,0 +1,189 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.Identity.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20251202005226_InitSnowflake_Identity")] + partial class InitSnowflake_Identity + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.IdentityUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Account") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("登录账号。"); + + b.Property("Avatar") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("头像地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("展示名称。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户(平台管理员为空)。"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("密码哈希。"); + + b.Property("Permissions") + .IsRequired() + .HasColumnType("text") + .HasComment("权限集合。"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("text") + .HasComment("角色集合。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Account") + .IsUnique(); + + b.ToTable("identity_users", null, t => + { + t.HasComment("管理后台账户实体(平台管理员、租户管理员或商户员工)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MiniUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Avatar") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("头像地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("昵称。"); + + b.Property("OpenId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("微信 OpenId。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UnionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("微信 UnionId,可能为空。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "OpenId") + .IsUnique(); + + b.ToTable("mini_users", null, t => + { + t.HasComment("小程序用户实体。"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.cs new file mode 100644 index 0000000..772ad91 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.cs @@ -0,0 +1,99 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb +{ + /// + public partial class InitSnowflake_Identity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "identity_users", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Account = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "登录账号。"), + DisplayName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "展示名称。"), + PasswordHash = table.Column(type: "character varying(256)", maxLength: 256, nullable: false, comment: "密码哈希。"), + MerchantId = table.Column(type: "bigint", nullable: true, comment: "所属商户(平台管理员为空)。"), + Roles = table.Column(type: "text", nullable: false, comment: "角色集合。"), + Permissions = table.Column(type: "text", nullable: false, comment: "权限集合。"), + Avatar = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "头像地址。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_identity_users", x => x.Id); + }, + comment: "管理后台账户实体(平台管理员、租户管理员或商户员工)。"); + + migrationBuilder.CreateTable( + name: "mini_users", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + OpenId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "微信 OpenId。"), + UnionId = table.Column(type: "character varying(128)", maxLength: 128, nullable: true, comment: "微信 UnionId,可能为空。"), + Nickname = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "昵称。"), + Avatar = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "头像地址。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_mini_users", x => x.Id); + }, + comment: "小程序用户实体。"); + + migrationBuilder.CreateIndex( + name: "IX_identity_users_TenantId", + table: "identity_users", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_identity_users_TenantId_Account", + table: "identity_users", + columns: new[] { "TenantId", "Account" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_mini_users_TenantId", + table: "mini_users", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_mini_users_TenantId_OpenId", + table: "mini_users", + columns: new[] { "TenantId", "OpenId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "identity_users"); + + migrationBuilder.DropTable( + name: "mini_users"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.Designer.cs new file mode 100644 index 0000000..aeeb414 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.Designer.cs @@ -0,0 +1,429 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.Identity.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20251202084523_AddRbacModel")] + partial class AddRbacModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.IdentityUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Account") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("登录账号。"); + + b.Property("Avatar") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("头像地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("展示名称。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户(平台管理员为空)。"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("密码哈希。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Account") + .IsUnique(); + + b.ToTable("identity_users", null, t => + { + t.HasComment("管理后台账户实体(平台管理员、租户管理员或商户员工)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MiniUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Avatar") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("头像地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("昵称。"); + + b.Property("OpenId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("微信 OpenId。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UnionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("微信 UnionId,可能为空。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "OpenId") + .IsUnique(); + + b.ToTable("mini_users", null, t => + { + t.HasComment("小程序用户实体。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("权限编码(租户内唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("权限名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("permissions", null, t => + { + t.HasComment("权限定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("角色编码(租户内唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("角色名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("roles", null, t => + { + t.HasComment("角色定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PermissionId") + .HasColumnType("bigint") + .HasComment("权限 ID。"); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasComment("角色 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "PermissionId") + .IsUnique(); + + b.ToTable("role_permissions", null, t => + { + t.HasComment("角色-权限关系。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.UserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasComment("角色 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("user_roles", null, t => + { + t.HasComment("用户-角色关系。"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.cs new file mode 100644 index 0000000..aa64905 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.cs @@ -0,0 +1,190 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb +{ + /// + public partial class AddRbacModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Permissions", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "Roles", + table: "identity_users"); + + migrationBuilder.CreateTable( + name: "permissions", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "权限名称。"), + Code = table.Column(type: "character varying(128)", maxLength: 128, nullable: false, comment: "权限编码(租户内唯一)。"), + Description = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "描述。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_permissions", x => x.Id); + }, + comment: "权限定义。"); + + migrationBuilder.CreateTable( + name: "role_permissions", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "bigint", nullable: false, comment: "角色 ID。"), + PermissionId = table.Column(type: "bigint", nullable: false, comment: "权限 ID。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_role_permissions", x => x.Id); + }, + comment: "角色-权限关系。"); + + migrationBuilder.CreateTable( + name: "roles", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "角色名称。"), + Code = table.Column(type: "character varying(64)", maxLength: 64, nullable: false, comment: "角色编码(租户内唯一)。"), + Description = table.Column(type: "character varying(256)", maxLength: 256, nullable: true, comment: "描述。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_roles", x => x.Id); + }, + comment: "角色定义。"); + + migrationBuilder.CreateTable( + name: "user_roles", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "bigint", nullable: false, comment: "用户 ID。"), + RoleId = table.Column(type: "bigint", nullable: false, comment: "角色 ID。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近一次更新时间(UTC),从未更新时为 null。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "软删除时间(UTC),未删除时为 null。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识,匿名或系统操作时为 null。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识,匿名或系统操作时为 null。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识(软删除),未删除时为 null。"), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID。") + }, + constraints: table => + { + table.PrimaryKey("PK_user_roles", x => x.Id); + }, + comment: "用户-角色关系。"); + + migrationBuilder.CreateIndex( + name: "IX_permissions_TenantId", + table: "permissions", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_permissions_TenantId_Code", + table: "permissions", + columns: new[] { "TenantId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_role_permissions_TenantId", + table: "role_permissions", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_role_permissions_TenantId_RoleId_PermissionId", + table: "role_permissions", + columns: new[] { "TenantId", "RoleId", "PermissionId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_roles_TenantId", + table: "roles", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_roles_TenantId_Code", + table: "roles", + columns: new[] { "TenantId", "Code" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_user_roles_TenantId", + table: "user_roles", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_user_roles_TenantId_UserId_RoleId", + table: "user_roles", + columns: new[] { "TenantId", "UserId", "RoleId" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "permissions"); + + migrationBuilder.DropTable( + name: "role_permissions"); + + migrationBuilder.DropTable( + name: "roles"); + + migrationBuilder.DropTable( + name: "user_roles"); + + migrationBuilder.AddColumn( + name: "Permissions", + table: "identity_users", + type: "text", + nullable: false, + defaultValue: "", + comment: "权限集合。"); + + migrationBuilder.AddColumn( + name: "Roles", + table: "identity_users", + type: "text", + nullable: false, + defaultValue: "", + comment: "角色集合。"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs new file mode 100644 index 0000000..a98f3c3 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs @@ -0,0 +1,426 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.Identity.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb +{ + [DbContext(typeof(IdentityDbContext))] + partial class IdentityDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.IdentityUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Account") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("登录账号。"); + + b.Property("Avatar") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("头像地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("展示名称。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户(平台管理员为空)。"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("密码哈希。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Account") + .IsUnique(); + + b.ToTable("identity_users", null, t => + { + t.HasComment("管理后台账户实体(平台管理员、租户管理员或商户员工)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MiniUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Avatar") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("头像地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("昵称。"); + + b.Property("OpenId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("微信 OpenId。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UnionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("微信 UnionId,可能为空。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "OpenId") + .IsUnique(); + + b.ToTable("mini_users", null, t => + { + t.HasComment("小程序用户实体。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("权限编码(租户内唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("权限名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("permissions", null, t => + { + t.HasComment("权限定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("角色编码(租户内唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("角色名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("roles", null, t => + { + t.HasComment("角色定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PermissionId") + .HasColumnType("bigint") + .HasComment("权限 ID。"); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasComment("角色 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "PermissionId") + .IsUnique(); + + b.ToTable("role_permissions", null, t => + { + t.HasComment("角色-权限关系。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.UserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasComment("角色 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("user_roles", null, t => + { + t.HasComment("用户-角色关系。"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs new file mode 100644 index 0000000..fc6058e --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/TakeoutAppDbContextModelSnapshot.cs @@ -0,0 +1,5796 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.App.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations +{ + [DbContext(typeof(TakeoutAppDbContext))] + partial class TakeoutAppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricAlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConditionJson") + .IsRequired() + .HasColumnType("text") + .HasComment("触发条件 JSON。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Enabled") + .HasColumnType("boolean") + .HasComment("是否启用。"); + + b.Property("MetricDefinitionId") + .HasColumnType("bigint") + .HasComment("关联指标。"); + + b.Property("NotificationChannels") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("通知渠道。"); + + b.Property("Severity") + .HasColumnType("integer") + .HasComment("告警级别。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MetricDefinitionId", "Severity"); + + b.ToTable("metric_alert_rules", null, t => + { + t.HasComment("指标告警规则。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("指标编码。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DefaultAggregation") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("默认聚合方式。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("说明。"); + + b.Property("DimensionsJson") + .HasColumnType("text") + .HasComment("维度描述 JSON。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("指标名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("metric_definitions", null, t => + { + t.HasComment("指标定义,描述可观测的数据点。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Analytics.Entities.MetricSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DimensionKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("维度键(JSON)。"); + + b.Property("MetricDefinitionId") + .HasColumnType("bigint") + .HasComment("指标定义 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Value") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)") + .HasComment("数值。"); + + b.Property("WindowEnd") + .HasColumnType("timestamp with time zone") + .HasComment("统计时间窗口结束。"); + + b.Property("WindowStart") + .HasColumnType("timestamp with time zone") + .HasComment("统计时间窗口开始。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MetricDefinitionId", "DimensionKey", "WindowStart", "WindowEnd") + .IsUnique(); + + b.ToTable("metric_snapshots", null, t => + { + t.HasComment("指标快照,用于大盘展示。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.Coupon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("券码或序列号。"); + + b.Property("CouponTemplateId") + .HasColumnType("bigint") + .HasComment("模板标识。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone") + .HasComment("到期时间。"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasComment("发放时间。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("订单 ID(已使用时记录)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasComment("使用时间。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("归属用户。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("coupons", null, t => + { + t.HasComment("用户领取的券。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.CouponTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowStack") + .HasColumnType("boolean") + .HasComment("是否允许叠加其他优惠。"); + + b.Property("ChannelsJson") + .HasColumnType("text") + .HasComment("发放渠道(JSON)。"); + + b.Property("ClaimedQuantity") + .HasColumnType("integer") + .HasComment("已领取数量。"); + + b.Property("CouponType") + .HasColumnType("integer") + .HasComment("券类型。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注。"); + + b.Property("DiscountCap") + .HasColumnType("numeric") + .HasComment("折扣上限(针对折扣券)。"); + + b.Property("MinimumSpend") + .HasColumnType("numeric") + .HasComment("最低消费门槛。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("模板名称。"); + + b.Property("ProductScopeJson") + .HasColumnType("text") + .HasComment("适用品类或商品范围(JSON)。"); + + b.Property("RelativeValidDays") + .HasColumnType("integer") + .HasComment("有效天数(相对发放时间)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("StoreScopeJson") + .HasColumnType("text") + .HasComment("适用门店 ID 集合(JSON)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TotalQuantity") + .HasColumnType("integer") + .HasComment("总发放数量上限。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone") + .HasComment("可用开始时间。"); + + b.Property("ValidTo") + .HasColumnType("timestamp with time zone") + .HasComment("可用结束时间。"); + + b.Property("Value") + .HasColumnType("numeric") + .HasComment("面值或折扣额度。"); + + b.HasKey("Id"); + + b.ToTable("coupon_templates", null, t => + { + t.HasComment("优惠券模板。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Coupons.Entities.PromotionCampaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AudienceDescription") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("目标人群描述。"); + + b.Property("BannerUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("营销素材(如 banner)。"); + + b.Property("Budget") + .HasColumnType("numeric") + .HasComment("预算金额。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndAt") + .HasColumnType("timestamp with time zone") + .HasComment("结束时间。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("活动名称。"); + + b.Property("PromotionType") + .HasColumnType("integer") + .HasComment("活动类型。"); + + b.Property("RulesJson") + .IsRequired() + .HasColumnType("text") + .HasComment("活动规则 JSON。"); + + b.Property("StartAt") + .HasColumnType("timestamp with time zone") + .HasComment("开始时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("活动状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.ToTable("promotion_campaigns", null, t => + { + t.HasComment("营销活动配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChatSessionId") + .HasColumnType("bigint") + .HasComment("会话标识。"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("消息内容。"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("消息类型(文字/图片/语音等)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsRead") + .HasColumnType("boolean") + .HasComment("是否已读。"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasComment("读取时间。"); + + b.Property("SenderType") + .HasColumnType("integer") + .HasComment("发送方类型。"); + + b.Property("SenderUserId") + .HasColumnType("bigint") + .HasComment("发送方用户 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ChatSessionId", "CreatedAt"); + + b.ToTable("chat_messages", null, t => + { + t.HasComment("会话消息。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.ChatSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgentUserId") + .HasColumnType("bigint") + .HasComment("当前客服员工 ID。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CustomerUserId") + .HasColumnType("bigint") + .HasComment("顾客用户 ID。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndedAt") + .HasColumnType("timestamp with time zone") + .HasComment("结束时间。"); + + b.Property("IsBotActive") + .HasColumnType("boolean") + .HasComment("是否机器人接待中。"); + + b.Property("SessionCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("会话编号。"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasComment("开始时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("会话状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("所属门店(可空为平台)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SessionCode") + .IsUnique(); + + b.ToTable("chat_sessions", null, t => + { + t.HasComment("客服会话。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AssignedAgentId") + .HasColumnType("bigint") + .HasComment("指派的客服。"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone") + .HasComment("关闭时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CustomerUserId") + .HasColumnType("bigint") + .HasComment("客户用户 ID。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasComment("工单详情。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单(如有)。"); + + b.Property("Priority") + .HasColumnType("integer") + .HasComment("优先级。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("工单主题。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TicketNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("工单编号。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "TicketNo") + .IsUnique(); + + b.ToTable("support_tickets", null, t => + { + t.HasComment("客服工单。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.CustomerService.Entities.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttachmentsJson") + .HasColumnType("text") + .HasComment("附件 JSON。"); + + b.Property("AuthorUserId") + .HasColumnType("bigint") + .HasComment("评论人 ID。"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("评论内容。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsInternal") + .HasColumnType("boolean") + .HasComment("是否内部备注。"); + + b.Property("SupportTicketId") + .HasColumnType("bigint") + .HasComment("工单标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SupportTicketId"); + + b.ToTable("ticket_comments", null, t => + { + t.HasComment("工单评论/流转记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Deliveries.Entities.DeliveryEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryOrderId") + .HasColumnType("bigint") + .HasComment("配送单标识。"); + + b.Property("EventType") + .HasColumnType("integer") + .HasComment("事件类型。"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("事件描述。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("Payload") + .HasColumnType("text") + .HasComment("原始数据 JSON。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "DeliveryOrderId", "EventType"); + + b.ToTable("delivery_events", null, t => + { + t.HasComment("配送状态事件流水。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Deliveries.Entities.DeliveryOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CourierName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("骑手姓名。"); + + b.Property("CourierPhone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("骑手电话。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveredAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间。"); + + b.Property("DeliveryFee") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("配送费。"); + + b.Property("DispatchedAt") + .HasColumnType("timestamp with time zone") + .HasComment("下发时间。"); + + b.Property("FailureReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("异常原因。"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("PickedUpAt") + .HasColumnType("timestamp with time zone") + .HasComment("取餐时间。"); + + b.Property("Provider") + .HasColumnType("integer") + .HasComment("配送服务商。"); + + b.Property("ProviderOrderId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("第三方配送单号。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OrderId") + .IsUnique(); + + b.ToTable("delivery_orders", null, t => + { + t.HasComment("配送单。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Distribution.Entities.AffiliateOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffiliatePartnerId") + .HasColumnType("bigint") + .HasComment("推广人标识。"); + + b.Property("BuyerUserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EstimatedCommission") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("预计佣金。"); + + b.Property("OrderAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("订单金额。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单。"); + + b.Property("SettledAt") + .HasColumnType("timestamp with time zone") + .HasComment("结算完成时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AffiliatePartnerId", "OrderId") + .IsUnique(); + + b.ToTable("affiliate_orders", null, t => + { + t.HasComment("分销订单记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Distribution.Entities.AffiliatePartner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelType") + .HasColumnType("integer") + .HasComment("渠道类型。"); + + b.Property("CommissionRate") + .HasColumnType("numeric") + .HasComment("分成比例(0-1)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("昵称或渠道名称。"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("Remarks") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("审核备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID(如绑定平台账号)。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "DisplayName"); + + b.ToTable("affiliate_partners", null, t => + { + t.HasComment("分销/推广合作伙伴。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Distribution.Entities.AffiliatePayout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffiliatePartnerId") + .HasColumnType("bigint") + .HasComment("合作伙伴标识。"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("结算金额。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasComment("打款时间。"); + + b.Property("Period") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("结算周期描述。"); + + b.Property("Remarks") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AffiliatePartnerId", "Period") + .IsUnique(); + + b.ToTable("affiliate_payouts", null, t => + { + t.HasComment("佣金结算记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CheckInCampaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowMakeupCount") + .HasColumnType("integer") + .HasComment("支持补签次数。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("活动描述。"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasComment("结束日期。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("活动名称。"); + + b.Property("RewardsJson") + .IsRequired() + .HasColumnType("text") + .HasComment("连签奖励 JSON。"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasComment("开始日期。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name"); + + b.ToTable("checkin_campaigns", null, t => + { + t.HasComment("签到活动配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CheckInRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckInCampaignId") + .HasColumnType("bigint") + .HasComment("活动标识。"); + + b.Property("CheckInDate") + .HasColumnType("timestamp with time zone") + .HasComment("签到日期(本地)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsMakeup") + .HasColumnType("boolean") + .HasComment("是否补签。"); + + b.Property("RewardJson") + .IsRequired() + .HasColumnType("text") + .HasComment("获得奖励 JSON。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "CheckInCampaignId", "UserId", "CheckInDate") + .IsUnique(); + + b.ToTable("checkin_records", null, t => + { + t.HasComment("用户签到记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CommunityComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorUserId") + .HasColumnType("bigint") + .HasComment("评论人。"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("评论内容。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsDeleted") + .HasColumnType("boolean") + .HasComment("状态。"); + + b.Property("ParentId") + .HasColumnType("bigint") + .HasComment("父级评论 ID。"); + + b.Property("PostId") + .HasColumnType("bigint") + .HasComment("动态标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "PostId", "CreatedAt"); + + b.ToTable("community_comments", null, t => + { + t.HasComment("社区评论。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CommunityPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorUserId") + .HasColumnType("bigint") + .HasComment("作者用户 ID。"); + + b.Property("CommentCount") + .HasColumnType("integer") + .HasComment("评论数。"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text") + .HasComment("内容。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("LikeCount") + .HasColumnType("integer") + .HasComment("点赞数。"); + + b.Property("MediaJson") + .HasColumnType("text") + .HasComment("媒体资源 JSON。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AuthorUserId", "CreatedAt"); + + b.ToTable("community_posts", null, t => + { + t.HasComment("社区动态。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Engagement.Entities.CommunityReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PostId") + .HasColumnType("bigint") + .HasComment("动态 ID。"); + + b.Property("ReactedAt") + .HasColumnType("timestamp with time zone") + .HasComment("时间戳。"); + + b.Property("ReactionType") + .HasColumnType("integer") + .HasComment("反应类型。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "PostId", "UserId") + .IsUnique(); + + b.ToTable("community_reactions", null, t => + { + t.HasComment("社区互动反馈。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.GroupBuying.Entities.GroupOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasComment("取消时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CurrentCount") + .HasColumnType("integer") + .HasComment("当前已参与人数。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndAt") + .HasColumnType("timestamp with time zone") + .HasComment("结束时间。"); + + b.Property("GroupOrderNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("拼单编号。"); + + b.Property("GroupPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("拼团价格。"); + + b.Property("LeaderUserId") + .HasColumnType("bigint") + .HasComment("团长用户 ID。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("关联商品或套餐。"); + + b.Property("StartAt") + .HasColumnType("timestamp with time zone") + .HasComment("开始时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("拼团状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("SucceededAt") + .HasColumnType("timestamp with time zone") + .HasComment("成团时间。"); + + b.Property("TargetCount") + .HasColumnType("integer") + .HasComment("成团需要的人数。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "GroupOrderNo") + .IsUnique(); + + b.ToTable("group_orders", null, t => + { + t.HasComment("拼单活动。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.GroupBuying.Entities.GroupParticipant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("GroupOrderId") + .HasColumnType("bigint") + .HasComment("拼单活动标识。"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasComment("参与时间。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("对应订单标识。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("参与状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "GroupOrderId", "UserId") + .IsUnique(); + + b.ToTable("group_participants", null, t => + { + t.HasComment("拼单参与者。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Inventory.Entities.InventoryAdjustment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdjustmentType") + .HasColumnType("integer") + .HasComment("调整类型。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("InventoryItemId") + .HasColumnType("bigint") + .HasComment("对应的库存记录标识。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人标识。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("调整数量,正数增加,负数减少。"); + + b.Property("Reason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("原因说明。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "InventoryItemId", "OccurredAt"); + + b.ToTable("inventory_adjustments", null, t => + { + t.HasComment("库存调整记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Inventory.Entities.InventoryBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BatchNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("批次编号。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpireDate") + .HasColumnType("timestamp with time zone") + .HasComment("过期日期。"); + + b.Property("ProductSkuId") + .HasColumnType("bigint") + .HasComment("SKU 标识。"); + + b.Property("ProductionDate") + .HasColumnType("timestamp with time zone") + .HasComment("生产日期。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("入库数量。"); + + b.Property("RemainingQuantity") + .HasColumnType("integer") + .HasComment("剩余数量。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "ProductSkuId", "BatchNumber") + .IsUnique(); + + b.ToTable("inventory_batches", null, t => + { + t.HasComment("SKU 批次信息。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Inventory.Entities.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BatchNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("批次编号,可为空表示混批。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpireDate") + .HasColumnType("timestamp with time zone") + .HasComment("过期日期。"); + + b.Property("Location") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("储位或仓位信息。"); + + b.Property("ProductSkuId") + .HasColumnType("bigint") + .HasComment("SKU 标识。"); + + b.Property("QuantityOnHand") + .HasColumnType("integer") + .HasComment("可用库存。"); + + b.Property("QuantityReserved") + .HasColumnType("integer") + .HasComment("已锁定库存(订单占用)。"); + + b.Property("SafetyStock") + .HasColumnType("integer") + .HasComment("安全库存阈值。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "ProductSkuId", "BatchNumber"); + + b.ToTable("inventory_items", null, t => + { + t.HasComment("SKU 在门店的库存信息。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberGrowthLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChangeValue") + .HasColumnType("integer") + .HasComment("变动数量。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CurrentValue") + .HasColumnType("integer") + .HasComment("当前成长值。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasComment("会员标识。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MemberId", "OccurredAt"); + + b.ToTable("member_growth_logs", null, t => + { + t.HasComment("成长值变动日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberPointLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BalanceAfterChange") + .HasColumnType("integer") + .HasComment("变动后余额。"); + + b.Property("ChangeAmount") + .HasColumnType("integer") + .HasComment("变动数量,可为负值。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone") + .HasComment("过期时间(如适用)。"); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasComment("会员标识。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("Reason") + .HasColumnType("integer") + .HasComment("变动原因。"); + + b.Property("SourceId") + .HasColumnType("bigint") + .HasComment("来源 ID(订单、活动等)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MemberId", "OccurredAt"); + + b.ToTable("member_point_ledgers", null, t => + { + t.HasComment("积分变动流水。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("头像。"); + + b.Property("BirthDate") + .HasColumnType("timestamp with time zone") + .HasComment("生日。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("GrowthValue") + .HasColumnType("integer") + .HasComment("成长值/经验值。"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasComment("注册时间。"); + + b.Property("MemberTierId") + .HasColumnType("bigint") + .HasComment("当前会员等级 ID。"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("手机号。"); + + b.Property("Nickname") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("昵称。"); + + b.Property("PointsBalance") + .HasColumnType("integer") + .HasComment("会员积分余额。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("会员状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Mobile") + .IsUnique(); + + b.ToTable("member_profiles", null, t => + { + t.HasComment("会员档案。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Membership.Entities.MemberTier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BenefitsJson") + .IsRequired() + .HasColumnType("text") + .HasComment("等级权益(JSON)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("等级名称。"); + + b.Property("RequiredGrowth") + .HasColumnType("integer") + .HasComment("所需成长值。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("member_tiers", null, t => + { + t.HasComment("会员等级定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.Merchant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("详细地址。"); + + b.Property("BrandAlias") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("品牌简称或别名。"); + + b.Property("BrandName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("品牌名称(对外展示)。"); + + b.Property("BusinessLicenseImageUrl") + .HasColumnType("text") + .HasComment("营业执照扫描件地址。"); + + b.Property("BusinessLicenseNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("营业执照号。"); + + b.Property("Category") + .HasColumnType("text") + .HasComment("品牌所属品类,如火锅、咖啡等。"); + + b.Property("City") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在城市。"); + + b.Property("ContactEmail") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("联系邮箱。"); + + b.Property("ContactPhone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("District") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在区县。"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasComment("入驻时间。"); + + b.Property("LastReviewedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次审核时间。"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasComment("纬度信息。"); + + b.Property("LegalPerson") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("法人或负责人姓名。"); + + b.Property("LogoUrl") + .HasColumnType("text") + .HasComment("品牌 Logo。"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasComment("经度信息。"); + + b.Property("Province") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在省份。"); + + b.Property("ReviewRemarks") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("审核备注或驳回原因。"); + + b.Property("ServicePhone") + .HasColumnType("text") + .HasComment("客服电话。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("入驻状态。"); + + b.Property("SupportEmail") + .HasColumnType("text") + .HasComment("客服邮箱。"); + + b.Property("TaxNumber") + .HasColumnType("text") + .HasComment("税号/统一社会信用代码。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("merchants", null, t => + { + t.HasComment("商户主体信息,承载入驻和资质审核结果。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContractNumber") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("合同编号。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone") + .HasComment("合同结束时间。"); + + b.Property("FileUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("合同文件存储地址。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户标识。"); + + b.Property("SignedAt") + .HasColumnType("timestamp with time zone") + .HasComment("签署时间。"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasComment("合同开始时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("合同状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TerminatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("终止时间。"); + + b.Property("TerminationReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("终止原因。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MerchantId", "ContractNumber") + .IsUnique(); + + b.ToTable("merchant_contracts", null, t => + { + t.HasComment("商户合同记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DocumentNumber") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("证照编号。"); + + b.Property("DocumentType") + .HasColumnType("integer") + .HasComment("证照类型。"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasComment("到期日期。"); + + b.Property("FileUrl") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("证照文件链接。"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone") + .HasComment("签发日期。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户标识。"); + + b.Property("Remarks") + .HasColumnType("text") + .HasComment("审核备注或驳回原因。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("审核状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MerchantId", "DocumentType"); + + b.ToTable("merchant_documents", null, t => + { + t.HasComment("商户提交的资质或证照材料。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Merchants.Entities.MerchantStaff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Email") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("邮箱地址。"); + + b.Property("IdentityUserId") + .HasColumnType("bigint") + .HasComment("登录账号 ID(指向统一身份体系)。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户标识。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("员工姓名。"); + + b.Property("PermissionsJson") + .HasColumnType("text") + .HasComment("自定义权限(JSON)。"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("手机号。"); + + b.Property("RoleType") + .HasColumnType("integer") + .HasComment("员工角色类型。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("员工状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("可选的关联门店 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "MerchantId", "Phone"); + + b.ToTable("merchant_staff", null, t => + { + t.HasComment("商户员工账号,支持门店维度分配。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Navigation.Entities.MapLocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Landmark") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("打车/导航落点描述。"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasComment("纬度。"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasComment("经度。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("名称。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("关联门店 ID,可空表示独立 POI。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("map_locations", null, t => + { + t.HasComment("地图 POI 信息,用于门店定位和推荐。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Navigation.Entities.NavigationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Channel") + .HasColumnType("integer") + .HasComment("来源通道(小程序、H5 等)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone") + .HasComment("请求时间。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店 ID。"); + + b.Property("TargetApp") + .HasColumnType("integer") + .HasComment("跳转的地图应用。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "UserId", "StoreId", "RequestedAt"); + + b.ToTable("navigation_requests", null, t => + { + t.HasComment("用户发起的导航请求日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Ordering.Entities.CartItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttributesJson") + .HasColumnType("text") + .HasComment("扩展 JSON(规格、加料选项等)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("商品或 SKU 标识。"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("商品名称快照。"); + + b.Property("ProductSkuId") + .HasColumnType("bigint") + .HasComment("SKU 标识。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("数量。"); + + b.Property("Remark") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("自定义备注(口味要求)。"); + + b.Property("ShoppingCartId") + .HasColumnType("bigint") + .HasComment("所属购物车标识。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("单价快照。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ShoppingCartId"); + + b.ToTable("cart_items", null, t => + { + t.HasComment("购物车条目。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Ordering.Entities.CartItemAddon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CartItemId") + .HasColumnType("bigint") + .HasComment("所属购物车条目。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExtraPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("附加价格。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("选项名称。"); + + b.Property("OptionId") + .HasColumnType("bigint") + .HasComment("选项 ID(可对应 ProductAddonOption)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.ToTable("cart_item_addons", null, t => + { + t.HasComment("购物车条目的加料/附加项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Ordering.Entities.CheckoutSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasComment("过期时间(UTC)。"); + + b.Property("SessionToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("会话 Token。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("会话状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.Property("ValidationResultJson") + .IsRequired() + .HasColumnType("text") + .HasComment("校验结果明细 JSON。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SessionToken") + .IsUnique(); + + b.ToTable("checkout_sessions", null, t => + { + t.HasComment("结账会话,记录校验上下文。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Ordering.Entities.ShoppingCart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryPreference") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("履约方式(堂食/自提/配送)缓存。"); + + b.Property("LastModifiedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次修改时间(UTC)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("购物车状态,包含正常/锁定。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TableContext") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("桌码或场景标识(扫码点餐)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "UserId", "StoreId") + .IsUnique(); + + b.ToTable("shopping_carts", null, t => + { + t.HasComment("用户购物车,按租户/门店隔离。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CancelReason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("取消原因。"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasComment("取消时间。"); + + b.Property("Channel") + .HasColumnType("integer") + .HasComment("下单渠道。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CustomerName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("顾客姓名。"); + + b.Property("CustomerPhone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("顾客手机号。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryType") + .HasColumnType("integer") + .HasComment("履约类型。"); + + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("优惠金额。"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间。"); + + b.Property("ItemsAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("商品总额。"); + + b.Property("OrderNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("订单号。"); + + b.Property("PaidAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("实付金额。"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasComment("支付时间。"); + + b.Property("PayableAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("应付金额。"); + + b.Property("PaymentStatus") + .HasColumnType("integer") + .HasComment("支付状态。"); + + b.Property("QueueNumber") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("排队号(如有)。"); + + b.Property("Remark") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注。"); + + b.Property("ReservationId") + .HasColumnType("bigint") + .HasComment("预约 ID。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店。"); + + b.Property("TableNo") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("就餐桌号。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OrderNo") + .IsUnique(); + + b.HasIndex("TenantId", "StoreId", "Status"); + + b.ToTable("orders", null, t => + { + t.HasComment("交易订单。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.OrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttributesJson") + .HasColumnType("text") + .HasComment("自定义属性 JSON。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DiscountAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("折扣金额。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("订单 ID。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("商品 ID。"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("商品名称。"); + + b.Property("Quantity") + .HasColumnType("integer") + .HasComment("数量。"); + + b.Property("SkuName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("SKU/规格描述。"); + + b.Property("SubTotal") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("小计。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("单位。"); + + b.Property("UnitPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("单价。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("OrderId"); + + b.HasIndex("TenantId", "OrderId"); + + b.ToTable("order_items", null, t => + { + t.HasComment("订单明细。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.OrderStatusHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注信息。"); + + b.Property("OccurredAt") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人标识(可为空表示系统)。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("订单标识。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("变更后的状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OrderId", "OccurredAt"); + + b.ToTable("order_status_histories", null, t => + { + t.HasComment("订单状态流转记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.RefundRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("申请金额。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单标识。"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasComment("审核完成时间。"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("申请原因。"); + + b.Property("RefundNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("退款单号。"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone") + .HasComment("用户提交时间。"); + + b.Property("ReviewNotes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("审核备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("退款状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "RefundNo") + .IsUnique(); + + b.ToTable("refund_requests", null, t => + { + t.HasComment("售后/退款申请。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Payments.Entities.PaymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("支付金额。"); + + b.Property("ChannelTransactionId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("第三方渠道单号。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Method") + .HasColumnType("integer") + .HasComment("支付方式。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单。"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone") + .HasComment("支付完成时间。"); + + b.Property("Payload") + .HasColumnType("text") + .HasComment("原始回调内容。"); + + b.Property("Remark") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("错误/备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("支付状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TradeNo") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("平台交易号。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "OrderId"); + + b.ToTable("payment_records", null, t => + { + t.HasComment("支付流水。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Payments.Entities.PaymentRefundRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("退款金额。"); + + b.Property("ChannelRefundId") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("渠道退款流水号。"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("完成时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("OrderId") + .HasColumnType("bigint") + .HasComment("关联订单标识。"); + + b.Property("Payload") + .HasColumnType("text") + .HasComment("渠道返回的原始数据 JSON。"); + + b.Property("PaymentRecordId") + .HasColumnType("bigint") + .HasComment("原支付记录标识。"); + + b.Property("RequestedAt") + .HasColumnType("timestamp with time zone") + .HasComment("退款请求时间。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("退款状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "PaymentRecordId"); + + b.ToTable("payment_refund_records", null, t => + { + t.HasComment("支付渠道退款流水。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("bigint") + .HasComment("所属分类。"); + + b.Property("CoverImage") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("主图。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasColumnType("text") + .HasComment("商品描述。"); + + b.Property("EnableDelivery") + .HasColumnType("boolean") + .HasComment("支持配送。"); + + b.Property("EnableDineIn") + .HasColumnType("boolean") + .HasComment("支持堂食。"); + + b.Property("EnablePickup") + .HasColumnType("boolean") + .HasComment("支持自提。"); + + b.Property("GalleryImages") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("Gallery 图片逗号分隔。"); + + b.Property("IsFeatured") + .HasColumnType("boolean") + .HasComment("是否热门推荐。"); + + b.Property("MaxQuantityPerOrder") + .HasColumnType("integer") + .HasComment("最大每单限购。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("商品名称。"); + + b.Property("OriginalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("原价。"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("现价。"); + + b.Property("SpuCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("商品编码。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("商品状态。"); + + b.Property("StockQuantity") + .HasColumnType("integer") + .HasComment("库存数量(可选)。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("所属门店。"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("副标题/卖点。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Unit") + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("售卖单位(份/杯等)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SpuCode") + .IsUnique(); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("products", null, t => + { + t.HasComment("商品(SPU)信息。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductAddonGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsRequired") + .HasColumnType("boolean") + .HasComment("是否必选。"); + + b.Property("MaxSelect") + .HasColumnType("integer") + .HasComment("最大选择数量。"); + + b.Property("MinSelect") + .HasColumnType("integer") + .HasComment("最小选择数量。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("分组名称。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("所属商品。"); + + b.Property("SelectionType") + .HasColumnType("integer") + .HasComment("选择类型。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ProductId", "Name"); + + b.ToTable("product_addon_groups", null, t => + { + t.HasComment("加料/做法分组。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductAddonOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddonGroupId") + .HasColumnType("bigint") + .HasComment("所属加料分组。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExtraPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("附加价格。"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasComment("是否默认选项。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("选项名称。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.ToTable("product_addon_options", null, t => + { + t.HasComment("加料选项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductAttributeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsRequired") + .HasColumnType("boolean") + .HasComment("是否必选。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("分组名称,例如“辣度”“份量”。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("所属商品标识。"); + + b.Property("SelectionType") + .HasColumnType("integer") + .HasComment("选择类型(单选/多选)。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("显示排序。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("关联门店,可为空表示所有门店共享。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "Name"); + + b.ToTable("product_attribute_groups", null, t => + { + t.HasComment("商品规格/属性分组。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductAttributeOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttributeGroupId") + .HasColumnType("bigint") + .HasComment("所属规格组。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("ExtraPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("附加价格。"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasComment("是否默认选中。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("选项名称。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "AttributeGroupId", "Name") + .IsUnique(); + + b.ToTable("product_attribute_options", null, t => + { + t.HasComment("商品规格选项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("分类描述。"); + + b.Property("IsEnabled") + .HasColumnType("boolean") + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("分类名称。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("所属门店。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("product_categories", null, t => + { + t.HasComment("商品分类。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductMediaAsset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Caption") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述或标题。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("MediaType") + .HasColumnType("integer") + .HasComment("媒体类型。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("商品标识。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("媒资链接。"); + + b.HasKey("Id"); + + b.ToTable("product_media_assets", null, t => + { + t.HasComment("商品媒资素材。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductPricingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConditionsJson") + .IsRequired() + .HasColumnType("text") + .HasComment("条件描述(JSON),如会员等级、渠道等。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone") + .HasComment("生效结束时间。"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("特殊价格。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("所属商品。"); + + b.Property("RuleType") + .HasColumnType("integer") + .HasComment("策略类型。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone") + .HasComment("生效开始时间。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("WeekdaysJson") + .HasColumnType("text") + .HasComment("生效星期(JSON 数组)。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ProductId", "RuleType"); + + b.ToTable("product_pricing_rules", null, t => + { + t.HasComment("商品价格策略,支持会员价/时段价等。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Products.Entities.ProductSku", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttributesJson") + .IsRequired() + .HasColumnType("text") + .HasComment("规格属性 JSON(记录选项 ID)。"); + + b.Property("Barcode") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("条形码。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("OriginalPrice") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("原价。"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("售价。"); + + b.Property("ProductId") + .HasColumnType("bigint") + .HasComment("所属商品标识。"); + + b.Property("SkuCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("SKU 编码。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StockQuantity") + .HasColumnType("integer") + .HasComment("可售库存。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Weight") + .HasPrecision(10, 3) + .HasColumnType("numeric(10,3)") + .HasComment("重量(千克)。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "SkuCode") + .IsUnique(); + + b.ToTable("product_skus", null, t => + { + t.HasComment("商品 SKU,记录具体规格组合价格。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Queues.Entities.QueueTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalledAt") + .HasColumnType("timestamp with time zone") + .HasComment("叫号时间。"); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasComment("取消时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EstimatedWaitMinutes") + .HasColumnType("integer") + .HasComment("预计等待分钟。"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasComment("过号时间。"); + + b.Property("PartySize") + .HasColumnType("integer") + .HasComment("就餐人数。"); + + b.Property("Remark") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("StoreId") + .HasColumnType("bigint"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TicketNumber") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("排队编号。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId"); + + b.HasIndex("TenantId", "StoreId", "TicketNumber") + .IsUnique(); + + b.ToTable("queue_tickets", null, t => + { + t.HasComment("排队叫号。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Reservations.Entities.Reservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CancelledAt") + .HasColumnType("timestamp with time zone") + .HasComment("取消时间。"); + + b.Property("CheckInCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("核销码/到店码。"); + + b.Property("CheckedInAt") + .HasColumnType("timestamp with time zone") + .HasComment("实际签到时间。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("CustomerName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("客户姓名。"); + + b.Property("CustomerPhone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PeopleCount") + .HasColumnType("integer") + .HasComment("用餐人数。"); + + b.Property("Remark") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注。"); + + b.Property("ReservationNo") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("预约号。"); + + b.Property("ReservationTime") + .HasColumnType("timestamp with time zone") + .HasComment("预约时间(UTC)。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店。"); + + b.Property("TablePreference") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("桌型/标签。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ReservationNo") + .IsUnique(); + + b.HasIndex("TenantId", "StoreId"); + + b.ToTable("reservations", null, t => + { + t.HasComment("预约/预订记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("详细地址。"); + + b.Property("Announcement") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("门店公告。"); + + b.Property("BusinessHours") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("门店营业时段描述(备用字符串)。"); + + b.Property("City") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在城市。"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("门店编码,便于扫码及外部对接。"); + + b.Property("Country") + .HasColumnType("text") + .HasComment("所在国家或地区。"); + + b.Property("CoverImageUrl") + .HasColumnType("text") + .HasComment("门店海报。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryRadiusKm") + .HasPrecision(6, 2) + .HasColumnType("numeric(6,2)") + .HasComment("默认配送半径(公里)。"); + + b.Property("Description") + .HasColumnType("text") + .HasComment("门店描述或公告。"); + + b.Property("District") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("区县信息。"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasComment("纬度。"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasComment("高德/腾讯地图经度。"); + + b.Property("ManagerName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("门店负责人姓名。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户标识。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("门店名称。"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("联系电话。"); + + b.Property("Province") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所在省份。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("门店当前运营状态。"); + + b.Property("SupportsDelivery") + .HasColumnType("boolean") + .HasComment("是否支持配送。"); + + b.Property("SupportsDineIn") + .HasColumnType("boolean") + .HasComment("是否支持堂食。"); + + b.Property("SupportsPickup") + .HasColumnType("boolean") + .HasComment("是否支持自提。"); + + b.Property("SupportsQueueing") + .HasColumnType("boolean") + .HasComment("支持排队叫号。"); + + b.Property("SupportsReservation") + .HasColumnType("boolean") + .HasComment("支持预约。"); + + b.Property("Tags") + .HasColumnType("text") + .HasComment("门店标签(逗号分隔)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.HasIndex("TenantId", "MerchantId"); + + b.ToTable("stores", null, t => + { + t.HasComment("门店信息,承载营业配置与能力。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreBusinessHour", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CapacityLimit") + .HasColumnType("integer") + .HasComment("最大接待容量或单量限制。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DayOfWeek") + .HasColumnType("integer") + .HasComment("星期几,0 表示周日。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndTime") + .HasColumnType("interval") + .HasComment("结束时间(本地时间)。"); + + b.Property("HourType") + .HasColumnType("integer") + .HasComment("时段类型(正常营业、休息、预约等)。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("StartTime") + .HasColumnType("interval") + .HasComment("开始时间(本地时间)。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "DayOfWeek"); + + b.ToTable("store_business_hours", null, t => + { + t.HasComment("门店营业时段配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreDeliveryZone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DeliveryFee") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("配送费。"); + + b.Property("EstimatedMinutes") + .HasColumnType("integer") + .HasComment("预计送达分钟。"); + + b.Property("MinimumOrderAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("起送价。"); + + b.Property("PolygonGeoJson") + .IsRequired() + .HasColumnType("text") + .HasComment("GeoJSON 表示的多边形范围。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("ZoneName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("区域名称。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "ZoneName"); + + b.ToTable("store_delivery_zones", null, t => + { + t.HasComment("门店配送范围配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreEmployeeShift", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EndTime") + .HasColumnType("interval") + .HasComment("结束时间。"); + + b.Property("Notes") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("备注。"); + + b.Property("RoleType") + .HasColumnType("integer") + .HasComment("排班角色。"); + + b.Property("ShiftDate") + .HasColumnType("timestamp with time zone") + .HasComment("班次日期。"); + + b.Property("StaffId") + .HasColumnType("bigint") + .HasComment("员工标识。"); + + b.Property("StartTime") + .HasColumnType("interval") + .HasComment("开始时间。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "ShiftDate", "StaffId") + .IsUnique(); + + b.ToTable("store_employee_shifts", null, t => + { + t.HasComment("门店员工排班记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("Date") + .HasColumnType("timestamp with time zone") + .HasComment("日期。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("IsClosed") + .HasColumnType("boolean") + .HasComment("是否全天闭店。"); + + b.Property("Reason") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("说明内容。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "Date") + .IsUnique(); + + b.ToTable("store_holidays", null, t => + { + t.HasComment("门店休息日或特殊营业日。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreTable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("bigint") + .HasComment("所在区域 ID。"); + + b.Property("Capacity") + .HasColumnType("integer") + .HasComment("可容纳人数。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("QrCodeUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("桌码二维码地址。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前桌台状态。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TableCode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("桌码。"); + + b.Property("Tags") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("桌台标签(堂食、快餐等)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "TableCode") + .IsUnique(); + + b.ToTable("store_tables", null, t => + { + t.HasComment("桌台信息与二维码绑定。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Stores.Entities.StoreTableArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("区域描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("区域名称。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值。"); + + b.Property("StoreId") + .HasColumnType("bigint") + .HasComment("门店标识。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StoreId", "Name") + .IsUnique(); + + b.ToTable("store_table_areas", null, t => + { + t.HasComment("门店桌台区域配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("text") + .HasComment("详细地址信息。"); + + b.Property("City") + .HasColumnType("text") + .HasComment("所在城市。"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("租户短编码,作为跨系统引用的唯一标识。"); + + b.Property("ContactEmail") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("主联系人邮箱。"); + + b.Property("ContactName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("主联系人姓名。"); + + b.Property("ContactPhone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("主联系人电话。"); + + b.Property("Country") + .HasColumnType("text") + .HasComment("所在国家/地区。"); + + b.Property("CoverImageUrl") + .HasColumnType("text") + .HasComment("品牌海报或封面图。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone") + .HasComment("服务生效时间(UTC)。"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone") + .HasComment("服务到期时间(UTC)。"); + + b.Property("Industry") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("所属行业,如餐饮、零售等。"); + + b.Property("LegalEntityName") + .HasColumnType("text") + .HasComment("法人或公司主体名称。"); + + b.Property("LogoUrl") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("LOGO 图片地址。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("租户全称或品牌名称。"); + + b.Property("PrimaryOwnerUserId") + .HasColumnType("bigint") + .HasComment("系统内对应的租户所有者账号 ID。"); + + b.Property("Province") + .HasColumnType("text") + .HasComment("所在省份或州。"); + + b.Property("Remarks") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("备注信息,用于运营记录特殊说明。"); + + b.Property("ShortName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("对外展示的简称。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("租户当前状态,涵盖审核、启用、停用等场景。"); + + b.Property("SuspendedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次暂停服务时间。"); + + b.Property("SuspensionReason") + .HasColumnType("text") + .HasComment("暂停或终止的原因说明。"); + + b.Property("Tags") + .HasColumnType("text") + .HasComment("业务标签集合(逗号分隔)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Website") + .HasColumnType("text") + .HasComment("官网或主要宣传链接。"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("tenants", null, t => + { + t.HasComment("平台租户信息,描述租户的生命周期与基础资料。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantBillingStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountDue") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("应付金额。"); + + b.Property("AmountPaid") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasComment("实付金额。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DueDate") + .HasColumnType("timestamp with time zone") + .HasComment("到期日。"); + + b.Property("LineItemsJson") + .HasColumnType("text") + .HasComment("账单明细 JSON,记录各项费用。"); + + b.Property("PeriodEnd") + .HasColumnType("timestamp with time zone") + .HasComment("账单周期结束时间。"); + + b.Property("PeriodStart") + .HasColumnType("timestamp with time zone") + .HasComment("账单周期开始时间。"); + + b.Property("StatementNo") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("账单编号,供对账查询。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("当前付款状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "StatementNo") + .IsUnique(); + + b.ToTable("tenant_billing_statements", null, t => + { + t.HasComment("租户账单,用于呈现周期性收费。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Channel") + .HasColumnType("integer") + .HasComment("发布通道(站内、邮件、短信等)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("通知正文。"); + + b.Property("MetadataJson") + .HasColumnType("text") + .HasComment("附加元数据 JSON。"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone") + .HasComment("租户是否已阅读。"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasComment("推送时间。"); + + b.Property("Severity") + .HasColumnType("integer") + .HasComment("通知重要级别。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("通知标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Channel", "SentAt"); + + b.ToTable("tenant_notifications", null, t => + { + t.HasComment("面向租户的站内通知或消息推送。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantPackage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("套餐描述,包含适用场景、权益等。"); + + b.Property("FeaturePoliciesJson") + .HasColumnType("text") + .HasComment("权益明细 JSON,记录自定义特性开关。"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasComment("是否仍可售卖。"); + + b.Property("MaxAccountCount") + .HasColumnType("integer") + .HasComment("允许创建的最大账号数。"); + + b.Property("MaxDeliveryOrders") + .HasColumnType("integer") + .HasComment("每月可调用的配送单数量上限。"); + + b.Property("MaxSmsCredits") + .HasColumnType("integer") + .HasComment("每月短信额度上限。"); + + b.Property("MaxStorageGb") + .HasColumnType("integer") + .HasComment("存储容量上限(GB)。"); + + b.Property("MaxStoreCount") + .HasColumnType("integer") + .HasComment("允许的最大门店数。"); + + b.Property("MonthlyPrice") + .HasColumnType("numeric") + .HasComment("月付价格,单位:人民币元。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("套餐名称,展示给租户的简称。"); + + b.Property("PackageType") + .HasColumnType("integer") + .HasComment("套餐分类(试用、标准、旗舰等)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("YearlyPrice") + .HasColumnType("numeric") + .HasComment("年付价格,单位:人民币元。"); + + b.HasKey("Id"); + + b.ToTable("tenant_packages", null, t => + { + t.HasComment("平台提供的租户套餐定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantQuotaUsage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("LastResetAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次重置时间。"); + + b.Property("LimitValue") + .HasColumnType("numeric") + .HasComment("当前配额上限。"); + + b.Property("QuotaType") + .HasColumnType("integer") + .HasComment("配额类型,例如门店数、短信条数等。"); + + b.Property("ResetCycle") + .HasColumnType("text") + .HasComment("配额刷新周期描述(如月、年)。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UsedValue") + .HasColumnType("numeric") + .HasComment("已消耗的数量。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "QuotaType") + .IsUnique(); + + b.ToTable("tenant_quota_usages", null, t => + { + t.HasComment("租户配额使用情况快照。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Tenants.Entities.TenantSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutoRenew") + .HasColumnType("boolean") + .HasComment("是否开启自动续费。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone") + .HasComment("订阅生效时间(UTC)。"); + + b.Property("EffectiveTo") + .HasColumnType("timestamp with time zone") + .HasComment("订阅到期时间(UTC)。"); + + b.Property("NextBillingDate") + .HasColumnType("timestamp with time zone") + .HasComment("下一个计费时间,配合自动续费使用。"); + + b.Property("Notes") + .HasColumnType("text") + .HasComment("运营备注信息。"); + + b.Property("ScheduledPackageId") + .HasColumnType("bigint") + .HasComment("若已排期升降配,对应的新套餐 ID。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("订阅当前状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("TenantPackageId") + .HasColumnType("bigint") + .HasComment("当前订阅关联的套餐标识。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "TenantPackageId"); + + b.ToTable("tenant_subscriptions", null, t => + { + t.HasComment("租户套餐订阅记录。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Orders.Entities.OrderItem", b => + { + b.HasOne("TakeoutSaaS.Domain.Orders.Entities.Order", null) + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj new file mode 100644 index 0000000..ba314f2 Binary files /dev/null and b/src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj differ diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs new file mode 100644 index 0000000..78a16e9 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Authorization; +using TakeoutSaaS.Module.Authorization.Policies; + +namespace TakeoutSaaS.Module.Authorization.Attributes; + +/// +/// 权限校验特性 +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class PermissionAuthorizeAttribute : AuthorizeAttribute +{ + public PermissionAuthorizeAttribute(params string[] permissions) + { + ArgumentNullException.ThrowIfNull(permissions); + var normalized = permissions + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (normalized.Length == 0) + { + throw new ArgumentException("至少需要一个权限标识", nameof(permissions)); + } + + Permissions = normalized; + Policy = PermissionAuthorizationPolicyProvider.BuildPolicyName(normalized); + } + + /// + /// 所需权限集合 + /// + public IReadOnlyCollection Permissions { get; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs new file mode 100644 index 0000000..0e368a0 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Module.Authorization.Policies; + +namespace TakeoutSaaS.Module.Authorization.Extensions; + +/// +/// 权限授权注入扩展 +/// +public static class AuthorizationServiceCollectionExtensions +{ + /// + /// 启用自定义权限策略提供者与处理器 + /// + public static IServiceCollection AddPermissionAuthorization(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); + return services; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs new file mode 100644 index 0000000..b8df4fe --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs @@ -0,0 +1,41 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace TakeoutSaaS.Module.Authorization.Policies; + +/// +/// 权限校验处理器 +/// +public sealed class PermissionAuthorizationHandler : AuthorizationHandler +{ + public const string PermissionClaimType = "permission"; + + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement) + { + if (context.User?.Identity?.IsAuthenticated != true) + { + return Task.CompletedTask; + } + + var userPermissions = context.User + .FindAll(PermissionClaimType) + .Select(claim => claim.Value) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (userPermissions.Count == 0) + { + return Task.CompletedTask; + } + + if (requirement.Permissions.Any(userPermissions.Contains)) + { + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs new file mode 100644 index 0000000..4f2e610 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace TakeoutSaaS.Module.Authorization.Policies; + +/// +/// 权限策略提供者(按需动态构建策略) +/// +public sealed class PermissionAuthorizationPolicyProvider(IOptions options) : DefaultAuthorizationPolicyProvider(options) +{ + public const string PolicyPrefix = "PERMISSION:"; + private readonly AuthorizationOptions _options = options.Value; + + public override Task GetPolicyAsync(string policyName) + { + if (policyName.StartsWith(PolicyPrefix, StringComparison.OrdinalIgnoreCase)) + { + var existingPolicy = _options.GetPolicy(policyName); + if (existingPolicy != null) + { + return Task.FromResult(existingPolicy); + } + + var permissions = ParsePermissions(policyName); + if (permissions.Length == 0) + { + return Task.FromResult(null); + } + + var policy = new AuthorizationPolicyBuilder() + .AddRequirements(new PermissionRequirement(permissions)) + .Build(); + + _options.AddPolicy(policyName, policy); + return Task.FromResult(policy); + } + + return base.GetPolicyAsync(policyName); + } + + /// + /// 根据权限集合构建策略名称 + /// + public static string BuildPolicyName(IEnumerable permissions) + => $"{PolicyPrefix}{string.Join('|', NormalizePermissions(permissions))}"; + + private static string[] ParsePermissions(string policyName) + { + var raw = policyName[PolicyPrefix.Length..]; + return NormalizePermissions(raw.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + } + + private static string[] NormalizePermissions(IEnumerable permissions) + => [.. permissions + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase)]; +} diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs new file mode 100644 index 0000000..b188ec0 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authorization; + +namespace TakeoutSaaS.Module.Authorization.Policies; + +/// +/// 权限要求:用于授权策略中定义所需的权限集合。 +/// +public sealed class PermissionRequirement(IReadOnlyCollection permissions) : IAuthorizationRequirement +{ + /// + /// 所需的权限集合。 + /// + public IReadOnlyCollection Permissions { get; } = permissions ?? throw new ArgumentNullException(nameof(permissions)); +} diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/TakeoutSaaS.Module.Authorization.csproj b/src/Modules/TakeoutSaaS.Module.Authorization/TakeoutSaaS.Module.Authorization.csproj new file mode 100644 index 0000000..e1c880a Binary files /dev/null and b/src/Modules/TakeoutSaaS.Module.Authorization/TakeoutSaaS.Module.Authorization.csproj differ diff --git a/src/Modules/TakeoutSaaS.Module.Delivery/TakeoutSaaS.Module.Delivery.csproj b/src/Modules/TakeoutSaaS.Module.Delivery/TakeoutSaaS.Module.Delivery.csproj new file mode 100644 index 0000000..b407eac --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Delivery/TakeoutSaaS.Module.Delivery.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + + + + + + diff --git a/src/Modules/TakeoutSaaS.Module.Dictionary/Extensions/DictionaryModuleExtensions.cs b/src/Modules/TakeoutSaaS.Module.Dictionary/Extensions/DictionaryModuleExtensions.cs new file mode 100644 index 0000000..97334aa --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Dictionary/Extensions/DictionaryModuleExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Application.Dictionary.Extensions; +using TakeoutSaaS.Infrastructure.Dictionary.Extensions; + +namespace TakeoutSaaS.Module.Dictionary.Extensions; + +/// +/// 字典模块服务扩展。 +/// +public static class DictionaryModuleExtensions +{ + /// + /// 注册字典模块应用层与基础设施。 + /// + public static IServiceCollection AddDictionaryModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddDictionaryApplication(); + services.AddDictionaryInfrastructure(configuration); + return services; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Dictionary/TakeoutSaaS.Module.Dictionary.csproj b/src/Modules/TakeoutSaaS.Module.Dictionary/TakeoutSaaS.Module.Dictionary.csproj new file mode 100644 index 0000000..b03dcec --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Dictionary/TakeoutSaaS.Module.Dictionary.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + + + + + + + diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs new file mode 100644 index 0000000..666c554 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TakeoutSaaS.Module.Messaging.Abstractions; + +/// +/// 消息发布抽象。 +/// +public interface IMessagePublisher +{ + /// + /// 发布消息到指定路由键。 + /// + Task PublishAsync(string routingKey, T message, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs new file mode 100644 index 0000000..1e7e0bd --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace TakeoutSaaS.Module.Messaging.Abstractions; + +/// +/// 消息订阅抽象。 +/// +public interface IMessageSubscriber : IAsyncDisposable +{ + /// + /// 订阅指定队列与路由键,处理后返回是否消费成功。 + /// + Task SubscribeAsync(string queue, string routingKey, Func> handler, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Extensions/MessagingServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Extensions/MessagingServiceCollectionExtensions.cs new file mode 100644 index 0000000..9a53b1a --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Extensions/MessagingServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Module.Messaging.Abstractions; +using TakeoutSaaS.Module.Messaging.Options; +using TakeoutSaaS.Module.Messaging.Serialization; +using TakeoutSaaS.Module.Messaging.Services; + +namespace TakeoutSaaS.Module.Messaging.Extensions; + +/// +/// 消息队列模块注册扩展。 +/// +public static class MessagingServiceCollectionExtensions +{ + /// + /// 注册 RabbitMQ 发布/订阅能力。 + /// + public static IServiceCollection AddMessagingModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("RabbitMQ")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Options/RabbitMqOptions.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Options/RabbitMqOptions.cs new file mode 100644 index 0000000..1b10e6e --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Options/RabbitMqOptions.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Messaging.Options; + +/// +/// RabbitMQ 连接与交换机配置。 +/// +public sealed class RabbitMqOptions +{ + /// + /// 主机名。 + /// + [Required] + public string Host { get; set; } = "localhost"; + + /// + /// 端口。 + /// + [Range(1, 65535)] + public int Port { get; set; } = 5672; + + /// + /// 用户名。 + /// + [Required] + public string Username { get; set; } = "guest"; + + /// + /// 密码。 + /// + [Required] + public string Password { get; set; } = "guest"; + + /// + /// 虚拟主机。 + /// + public string VirtualHost { get; set; } = "/"; + + /// + /// 默认交换机名称。 + /// + [Required] + public string Exchange { get; set; } = "takeout.events"; + + /// + /// 交换机类型,默认 topic。 + /// + public string ExchangeType { get; set; } = "topic"; + + /// + /// 消费预取数量。 + /// + [Range(1, 1000)] + public ushort PrefetchCount { get; set; } = 20; +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Serialization/JsonMessageSerializer.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Serialization/JsonMessageSerializer.cs new file mode 100644 index 0000000..a17186c --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Serialization/JsonMessageSerializer.cs @@ -0,0 +1,22 @@ +using System.Text; +using System.Text.Json; + +namespace TakeoutSaaS.Module.Messaging.Serialization; + +/// +/// 消息 JSON 序列化器。 +/// +public sealed class JsonMessageSerializer +{ + private static readonly JsonSerializerOptions DefaultOptions = new(JsonSerializerDefaults.Web); + + /// + /// 序列化消息。 + /// + public byte[] Serialize(T message) => Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message, DefaultOptions)); + + /// + /// 反序列化消息。 + /// + public T? Deserialize(byte[] body) => JsonSerializer.Deserialize(body, DefaultOptions); +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqConnectionFactory.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqConnectionFactory.cs new file mode 100644 index 0000000..ad844c2 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqConnectionFactory.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Options; +using RabbitMQ.Client; +using TakeoutSaaS.Module.Messaging.Options; + +namespace TakeoutSaaS.Module.Messaging.Services; + +/// +/// RabbitMQ 连接工厂封装。 +/// +public sealed class RabbitMqConnectionFactory(IOptionsMonitor optionsMonitor) +{ + /// + /// 创建连接。 + /// + public IConnection CreateConnection() + { + var options = optionsMonitor.CurrentValue; + var factory = new ConnectionFactory + { + HostName = options.Host, + Port = options.Port, + UserName = options.Username, + Password = options.Password, + VirtualHost = options.VirtualHost, + DispatchConsumersAsync = true + }; + + return factory.CreateConnection(); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs new file mode 100644 index 0000000..701a992 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs @@ -0,0 +1,67 @@ +using System; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RabbitMQ.Client; +using TakeoutSaaS.Module.Messaging.Abstractions; +using TakeoutSaaS.Module.Messaging.Options; +using TakeoutSaaS.Module.Messaging.Serialization; + +namespace TakeoutSaaS.Module.Messaging.Services; + +/// +/// RabbitMQ 消息发布实现。 +/// +public sealed class RabbitMqMessagePublisher(RabbitMqConnectionFactory connectionFactory, IOptionsMonitor optionsMonitor, JsonMessageSerializer serializer, ILogger logger) + : IMessagePublisher, IAsyncDisposable +{ + private IConnection? _connection; + private IModel? _channel; + private bool _disposed; + + /// + public Task PublishAsync(string routingKey, T message, CancellationToken cancellationToken = default) + { + EnsureChannel(); + var options = optionsMonitor.CurrentValue; + + var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available."); + channel.ExchangeDeclare(options.Exchange, options.ExchangeType, durable: true, autoDelete: false); + var body = serializer.Serialize(message); + var props = channel.CreateBasicProperties(); + props.ContentType = "application/json"; + props.DeliveryMode = 2; + props.MessageId = Guid.NewGuid().ToString("N"); + + channel.BasicPublish(options.Exchange, routingKey, props, body); + logger.LogDebug("发布消息到交换机 {Exchange} RoutingKey {RoutingKey}", options.Exchange, routingKey); + return Task.CompletedTask; + } + + private void EnsureChannel() + { + if (_channel != null && _channel.IsOpen) + { + return; + } + + _connection ??= connectionFactory.CreateConnection(); + _channel = _connection.CreateModel(); + } + + /// + /// 释放 RabbitMQ 资源。 + /// + public ValueTask DisposeAsync() + { + if (_disposed) + { + return ValueTask.CompletedTask; + } + + _disposed = true; + _channel?.Dispose(); + _connection?.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs new file mode 100644 index 0000000..1acba98 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using TakeoutSaaS.Module.Messaging.Abstractions; +using TakeoutSaaS.Module.Messaging.Options; +using TakeoutSaaS.Module.Messaging.Serialization; + +namespace TakeoutSaaS.Module.Messaging.Services; + +/// +/// RabbitMQ 消费者实现。 +/// +public sealed class RabbitMqMessageSubscriber(RabbitMqConnectionFactory connectionFactory, IOptionsMonitor optionsMonitor, JsonMessageSerializer serializer, ILogger logger) + : IMessageSubscriber +{ + private IConnection? _connection; + private IModel? _channel; + private bool _disposed; + + /// + public async Task SubscribeAsync(string queue, string routingKey, Func> handler, CancellationToken cancellationToken = default) + { + EnsureChannel(); + var options = optionsMonitor.CurrentValue; + + var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available."); + + channel.ExchangeDeclare(options.Exchange, options.ExchangeType, durable: true, autoDelete: false); + channel.QueueDeclare(queue, durable: true, exclusive: false, autoDelete: false); + channel.QueueBind(queue, options.Exchange, routingKey); + channel.BasicQos(0, options.PrefetchCount, global: false); + + var consumer = new AsyncEventingBasicConsumer(channel); + consumer.Received += async (_, ea) => + { + var message = serializer.Deserialize(ea.Body.ToArray()); + if (message == null) + { + channel.BasicAck(ea.DeliveryTag, multiple: false); + return; + } + + var success = false; + try + { + success = await handler(message, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "处理消息失败:{RoutingKey}", ea.RoutingKey); + } + + if (success) + { + channel.BasicAck(ea.DeliveryTag, multiple: false); + } + else + { + channel.BasicNack(ea.DeliveryTag, multiple: false, requeue: false); + } + }; + + channel.BasicConsume(queue, autoAck: false, consumer); + await Task.CompletedTask.ConfigureAwait(false); + } + + private void EnsureChannel() + { + if (_channel != null && _channel.IsOpen) + { + return; + } + + _connection ??= connectionFactory.CreateConnection(); + _channel = _connection.CreateModel(); + } + + /// + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + await Task.Run(() => + { + _channel?.Dispose(); + _connection?.Dispose(); + }).ConfigureAwait(false); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csproj b/src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csproj new file mode 100644 index 0000000..ef94b34 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Messaging/TakeoutSaaS.Module.Messaging.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs new file mode 100644 index 0000000..79f5a29 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TakeoutSaaS.Module.Scheduler.Abstractions; + +/// +/// 周期性任务注册抽象。 +/// +public interface IRecurringJobRegistrar +{ + /// + /// 注册所有预设的周期性任务。 + /// + Task RegisterAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs new file mode 100644 index 0000000..db86c46 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Extensions/SchedulerServiceCollectionExtensions.cs @@ -0,0 +1,71 @@ +using Hangfire; +using Hangfire.PostgreSql; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Scheduler.Abstractions; +using TakeoutSaaS.Module.Scheduler.HostedServices; +using TakeoutSaaS.Module.Scheduler.Jobs; +using TakeoutSaaS.Module.Scheduler.Options; +using TakeoutSaaS.Module.Scheduler.Services; + +namespace TakeoutSaaS.Module.Scheduler.Extensions; + +/// +/// 调度模块注册扩展(默认 Hangfire)。 +/// +public static class SchedulerServiceCollectionExtensions +{ + /// + /// 注册调度模块。 + /// + public static IServiceCollection AddSchedulerModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("Scheduler")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddHangfire((serviceProvider, config) => + { + var options = serviceProvider.GetRequiredService>().CurrentValue; + config + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UsePostgreSqlStorage(storage => + { + storage.UseNpgsqlConnection(options.ConnectionString); + }); + }); + + services.AddHangfireServer((serviceProvider, options) => + { + var scheduler = serviceProvider.GetRequiredService>().CurrentValue; + options.WorkerCount = scheduler.WorkerCount ?? options.WorkerCount; + }); + + services.AddSingleton(); + services.AddHostedService(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + /// + /// 启用 Hangfire Dashboard(默认关闭,可通过配置开启)。 + /// + public static IApplicationBuilder UseSchedulerDashboard(this IApplicationBuilder app, IConfiguration configuration) + { + var options = configuration.GetSection("Scheduler").Get(); + if (options is { DashboardEnabled: true }) + { + app.UseHangfireDashboard(options.DashboardPath); + } + + return app; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/HostedServices/RecurringJobHostedService.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/HostedServices/RecurringJobHostedService.cs new file mode 100644 index 0000000..b2dca9b --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/HostedServices/RecurringJobHostedService.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Module.Scheduler.Abstractions; + +namespace TakeoutSaaS.Module.Scheduler.HostedServices; + +/// +/// 启动时注册周期性任务的宿主服务。 +/// +public sealed class RecurringJobHostedService(IRecurringJobRegistrar registrar, ILogger logger) : IHostedService +{ + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + await registrar.RegisterAsync(cancellationToken).ConfigureAwait(false); + logger.LogInformation("调度任务已注册"); + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/CouponExpireJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/CouponExpireJob.cs new file mode 100644 index 0000000..294c2da --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/CouponExpireJob.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; + +namespace TakeoutSaaS.Module.Scheduler.Jobs; + +/// +/// 优惠券过期处理任务(占位实现)。 +/// +public sealed class CouponExpireJob(ILogger logger) +{ + /// + /// 执行优惠券过期清理。 + /// + public Task ExecuteAsync() + { + logger.LogInformation("定时任务:处理已过期优惠券(占位实现)"); + return Task.CompletedTask; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/LogCleanupJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/LogCleanupJob.cs new file mode 100644 index 0000000..aa67c70 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/LogCleanupJob.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; + +namespace TakeoutSaaS.Module.Scheduler.Jobs; + +/// +/// 日志清理任务(占位实现)。 +/// +public sealed class LogCleanupJob(ILogger logger) +{ + /// + /// 执行日志清理。 + /// + public Task ExecuteAsync() + { + logger.LogInformation("定时任务:清理历史日志(占位实现)"); + return Task.CompletedTask; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderTimeoutJob.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderTimeoutJob.cs new file mode 100644 index 0000000..80d7513 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Jobs/OrderTimeoutJob.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; + +namespace TakeoutSaaS.Module.Scheduler.Jobs; + +/// +/// 订单超时取消任务(占位,后续接入订单服务)。 +/// +public sealed class OrderTimeoutJob(ILogger logger) +{ + /// + /// 执行超时订单检查。 + /// + public Task ExecuteAsync() + { + logger.LogInformation("定时任务:检查超时未支付订单并取消(占位实现)"); + return Task.CompletedTask; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Options/SchedulerOptions.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Options/SchedulerOptions.cs new file mode 100644 index 0000000..880790c --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Options/SchedulerOptions.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Scheduler.Options; + +/// +/// 调度模块配置。 +/// +public sealed class SchedulerOptions +{ + /// + /// Hangfire 存储使用的连接字符串。 + /// + [Required] + public string ConnectionString { get; set; } = string.Empty; + + /// + /// 工作线程数,默认根据 CPU 计算。 + /// + [Range(1, 100)] + public int? WorkerCount { get; set; } + + /// + /// 是否启用 Dashboard(默认 false,待 AdminUI 接入)。 + /// + public bool DashboardEnabled { get; set; } + + /// + /// Dashboard 路径。 + /// + public string DashboardPath { get; set; } = "/hangfire"; +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs new file mode 100644 index 0000000..6e3d559 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Services/RecurringJobRegistrar.cs @@ -0,0 +1,20 @@ +using Hangfire; +using TakeoutSaaS.Module.Scheduler.Abstractions; +using TakeoutSaaS.Module.Scheduler.Jobs; + +namespace TakeoutSaaS.Module.Scheduler.Services; + +/// +/// 周期性任务注册器。 +/// +public sealed class RecurringJobRegistrar : IRecurringJobRegistrar +{ + /// + public Task RegisterAsync(CancellationToken cancellationToken = default) + { + RecurringJob.AddOrUpdate("orders.timeout-cancel", job => job.ExecuteAsync(), "*/5 * * * *"); + RecurringJob.AddOrUpdate("coupons.expire", job => job.ExecuteAsync(), "0 */1 * * *"); + RecurringJob.AddOrUpdate("logs.cleanup", job => job.ExecuteAsync(), "0 3 * * *"); + return Task.CompletedTask; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj b/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj new file mode 100644 index 0000000..6c7f697 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/TakeoutSaaS.Module.Scheduler.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs new file mode 100644 index 0000000..5cf7dd5 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Module.Sms.Models; + +namespace TakeoutSaaS.Module.Sms.Abstractions; + +/// +/// 短信发送抽象。 +/// +public interface ISmsSender +{ + /// + /// 服务商类型。 + /// + SmsProviderKind Provider { get; } + + /// + /// 发送短信。 + /// + Task SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSenderResolver.cs b/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSenderResolver.cs new file mode 100644 index 0000000..f3385d2 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSenderResolver.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Module.Sms.Abstractions; + +/// +/// 短信服务商解析器。 +/// +public interface ISmsSenderResolver +{ + /// + /// 获取指定服务商的发送器。 + /// + ISmsSender Resolve(SmsProviderKind? provider = null); +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Extensions/SmsServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Sms/Extensions/SmsServiceCollectionExtensions.cs new file mode 100644 index 0000000..651c143 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Extensions/SmsServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Module.Sms.Abstractions; +using TakeoutSaaS.Module.Sms.Options; +using TakeoutSaaS.Module.Sms.Services; + +namespace TakeoutSaaS.Module.Sms.Extensions; + +/// +/// 短信模块 DI 注册扩展。 +/// +public static class SmsServiceCollectionExtensions +{ + /// + /// 注册短信模块(包含腾讯云、阿里云实现)。 + /// + public static IServiceCollection AddSmsModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("Sms")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddHttpClient(nameof(TencentSmsSender)); + services.AddHttpClient(nameof(AliyunSmsSender)); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs b/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs new file mode 100644 index 0000000..643f299 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +namespace TakeoutSaaS.Module.Sms.Models; + +/// +/// 短信发送请求。 +/// +public sealed class SmsSendRequest +{ + /// + /// 初始化短信发送请求。 + /// + /// 目标手机号码(含国家码,如 +86xxxxxxxxxxx)。 + /// 模版编号。 + /// 模版变量。 + /// 短信签名。 + public SmsSendRequest(string phoneNumber, string templateCode, IDictionary variables, string? signName = null) + { + PhoneNumber = phoneNumber; + TemplateCode = templateCode; + Variables = new Dictionary(variables); + SignName = signName; + } + + /// + /// 目标手机号。 + /// + public string PhoneNumber { get; } + + /// + /// 模版编号。 + /// + public string TemplateCode { get; } + + /// + /// 模版变量。 + /// + public IReadOnlyDictionary Variables { get; } + + /// + /// 可选的签名。 + /// + public string? SignName { get; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendResult.cs b/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendResult.cs new file mode 100644 index 0000000..afc81fb --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendResult.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Module.Sms.Models; + +/// +/// 短信发送结果。 +/// +public sealed class SmsSendResult +{ + /// + /// 是否发送成功。 + /// + public bool Success { get; init; } + + /// + /// 平台返回的请求标识。 + /// + public string? RequestId { get; init; } + + /// + /// 描述信息。 + /// + public string? Message { get; init; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Options/AliyunSmsOptions.cs b/src/Modules/TakeoutSaaS.Module.Sms/Options/AliyunSmsOptions.cs new file mode 100644 index 0000000..cfa09b1 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Options/AliyunSmsOptions.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Sms.Options; + +/// +/// 阿里云短信配置。 +/// +public sealed class AliyunSmsOptions +{ + /// + /// AccessKeyId。 + /// + [Required] + public string AccessKeyId { get; set; } = string.Empty; + + /// + /// AccessKeySecret。 + /// + [Required] + public string AccessKeySecret { get; set; } = string.Empty; + + /// + /// 短信服务域名。 + /// + public string Endpoint { get; set; } = "dysmsapi.aliyuncs.com"; + + /// + /// 默认签名。 + /// + public string? SignName { get; set; } + + /// + /// 地域 ID。 + /// + public string Region { get; set; } = "cn-hangzhou"; +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs b/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs new file mode 100644 index 0000000..5520760 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Module.Sms; + +namespace TakeoutSaaS.Module.Sms.Options; + +/// +/// 短信模块配置。 +/// +public sealed class SmsOptions +{ + /// + /// 默认服务商,默认为腾讯云。 + /// + public SmsProviderKind Provider { get; set; } = SmsProviderKind.Tencent; + + /// + /// 默认签名。 + /// + public string? DefaultSignName { get; set; } + + /// + /// 是否启用模拟发送(仅日志,不实际调用),方便开发环境。 + /// + public bool UseMock { get; set; } + + /// + /// 腾讯云短信配置。 + /// + [Required] + public TencentSmsOptions Tencent { get; set; } = new(); + + /// + /// 阿里云短信配置。 + /// + [Required] + public AliyunSmsOptions Aliyun { get; set; } = new(); + + /// + /// 场景与模板映射(如 login: TEMPLATE_ID)。 + /// + public Dictionary SceneTemplates { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Options/TencentSmsOptions.cs b/src/Modules/TakeoutSaaS.Module.Sms/Options/TencentSmsOptions.cs new file mode 100644 index 0000000..3e02bd4 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Options/TencentSmsOptions.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Sms.Options; + +/// +/// 腾讯云短信配置。 +/// +public sealed class TencentSmsOptions +{ + /// + /// SecretId。 + /// + [Required] + public string SecretId { get; set; } = string.Empty; + + /// + /// SecretKey。 + /// + [Required] + public string SecretKey { get; set; } = string.Empty; + + /// + /// 应用 SdkAppId。 + /// + [Required] + public string SdkAppId { get; set; } = string.Empty; + + /// + /// 默认签名。 + /// + public string? SignName { get; set; } + + /// + /// 默认地域。 + /// + public string Region { get; set; } = "ap-guangzhou"; + + /// + /// 接口域名。 + /// + public string Endpoint { get; set; } = "https://sms.tencentcloudapi.com"; +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs new file mode 100644 index 0000000..c94a47c --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs @@ -0,0 +1,35 @@ +using System.Net.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Sms.Abstractions; +using TakeoutSaaS.Module.Sms.Models; +using TakeoutSaaS.Module.Sms.Options; + +namespace TakeoutSaaS.Module.Sms.Services; + +/// +/// 阿里云短信发送实现(简化版,占位可扩展正式签名流程)。 +/// +public sealed class AliyunSmsSender(IHttpClientFactory httpClientFactory, IOptionsMonitor optionsMonitor, ILogger logger) + : ISmsSender +{ + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + + /// + public SmsProviderKind Provider => SmsProviderKind.Aliyun; + + /// + public Task SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default) + { + var options = optionsMonitor.CurrentValue; + if (options.UseMock) + { + logger.LogInformation("Mock 发送阿里云短信到 {Phone}, Template:{Template}", request.PhoneNumber, request.TemplateCode); + return Task.FromResult(new SmsSendResult { Success = true, Message = "Mocked" }); + } + + // 占位:保留待接入阿里云正式签名流程,当前返回未实现。 + logger.LogWarning("阿里云短信尚未启用,请配置腾讯云或开启 UseMock。"); + return Task.FromResult(new SmsSendResult { Success = false, Message = "Aliyun SMS not enabled" }); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs new file mode 100644 index 0000000..e47f6be --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Sms.Abstractions; +using TakeoutSaaS.Module.Sms.Options; + +namespace TakeoutSaaS.Module.Sms.Services; + +/// +/// 短信服务商解析器。 +/// +public sealed class SmsSenderResolver(IOptionsMonitor optionsMonitor, IEnumerable senders) : ISmsSenderResolver +{ + private readonly IReadOnlyDictionary _map = senders.ToDictionary(x => x.Provider); + + /// + public ISmsSender Resolve(SmsProviderKind? provider = null) + { + var key = provider ?? optionsMonitor.CurrentValue.Provider; + if (_map.TryGetValue(key, out var sender)) + { + return sender; + } + + throw new InvalidOperationException($"未注册短信服务商:{key}"); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs new file mode 100644 index 0000000..f7b9737 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs @@ -0,0 +1,136 @@ +using System.Globalization; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Sms.Abstractions; +using TakeoutSaaS.Module.Sms.Models; +using TakeoutSaaS.Module.Sms.Options; + +namespace TakeoutSaaS.Module.Sms.Services; + +/// +/// 腾讯云短信发送实现(TC3-HMAC 签名)。 +/// +public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOptionsMonitor optionsMonitor, ILogger logger) + : ISmsSender +{ + private const string Service = "sms"; + private const string Action = "SendSms"; + private const string Version = "2021-01-11"; + + /// + public SmsProviderKind Provider => SmsProviderKind.Tencent; + + /// + public async Task SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default) + { + var options = optionsMonitor.CurrentValue; + if (options.UseMock) + { + logger.LogInformation("Mock 发送短信到 {Phone}, Template:{Template}, Vars:{Vars}", request.PhoneNumber, request.TemplateCode, JsonSerializer.Serialize(request.Variables)); + return new SmsSendResult { Success = true, Message = "Mocked" }; + } + + var tencent = options.Tencent; + var payload = BuildPayload(request, tencent); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var date = DateTimeOffset.FromUnixTimeSeconds(timestamp).UtcDateTime.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + var host = new Uri(tencent.Endpoint).Host; + var canonicalRequest = BuildCanonicalRequest(payload, host, tencent.Endpoint.StartsWith("https", StringComparison.OrdinalIgnoreCase)); + var stringToSign = BuildStringToSign(canonicalRequest, timestamp, date); + var signature = Sign(stringToSign, tencent.SecretKey, date); + + using var httpClient = httpClientFactory.CreateClient(nameof(TencentSmsSender)); + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, tencent.Endpoint) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + + httpRequest.Headers.Add("Host", host); + httpRequest.Headers.Add("X-TC-Action", Action); + httpRequest.Headers.Add("X-TC-Version", Version); + httpRequest.Headers.Add("X-TC-Timestamp", timestamp.ToString(CultureInfo.InvariantCulture)); + httpRequest.Headers.Add("X-TC-Region", tencent.Region); + httpRequest.Headers.Add("Authorization", + $"TC3-HMAC-SHA256 Credential={tencent.SecretId}/{date}/{Service}/tc3_request, SignedHeaders=content-type;host, Signature={signature}"); + + var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + logger.LogWarning("腾讯云短信发送失败:{Status} {Content}", response.StatusCode, content); + return new SmsSendResult { Success = false, Message = content }; + } + + using var doc = JsonDocument.Parse(content); + var root = doc.RootElement.GetProperty("Response"); + var status = root.GetProperty("SendStatusSet")[0]; + var code = status.GetProperty("Code").GetString(); + var message = status.GetProperty("Message").GetString(); + var requestId = root.GetProperty("RequestId").GetString(); + + var success = string.Equals(code, "Ok", StringComparison.OrdinalIgnoreCase); + return new SmsSendResult + { + Success = success, + RequestId = requestId, + Message = message + }; + } + + private static string BuildPayload(SmsSendRequest request, TencentSmsOptions options) + { + var payload = new + { + PhoneNumberSet = new[] { request.PhoneNumber }, + SmsSdkAppId = options.SdkAppId, + SignName = request.SignName ?? options.SignName, + TemplateId = request.TemplateCode, + TemplateParamSet = request.Variables.Values.ToArray() + }; + + return JsonSerializer.Serialize(payload); + } + + private static string BuildCanonicalRequest(string payload, string host, bool useHttps) + { + _ = useHttps; + var hashedPayload = HashSha256(payload); + var canonicalHeaders = $"content-type:application/json\nhost:{host}\n"; + return $"POST\n/\n\n{canonicalHeaders}\ncontent-type;host\n{hashedPayload}"; + } + + private static string BuildStringToSign(string canonicalRequest, long timestamp, string date) + { + var hashedRequest = HashSha256(canonicalRequest); + return $"TC3-HMAC-SHA256\n{timestamp}\n{date}/{Service}/tc3_request\n{hashedRequest}"; + } + + private static string Sign(string stringToSign, string secretKey, string date) + { + static byte[] HmacSha256(byte[] key, string msg) => new HMACSHA256(key).ComputeHash(Encoding.UTF8.GetBytes(msg)); + + var secretDate = HmacSha256(Encoding.UTF8.GetBytes($"TC3{secretKey}"), date); + var secretService = HmacSha256(secretDate, Service); + var secretSigning = HmacSha256(secretService, "tc3_request"); + var signatureBytes = new HMACSHA256(secretSigning).ComputeHash(Encoding.UTF8.GetBytes(stringToSign)); + return Convert.ToHexString(signatureBytes).ToLowerInvariant(); + } + + private static string HashSha256(string raw) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); + var builder = new StringBuilder(bytes.Length * 2); + foreach (var b in bytes) + { + builder.Append(b.ToString("x2", CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/SmsProviderKind.cs b/src/Modules/TakeoutSaaS.Module.Sms/SmsProviderKind.cs new file mode 100644 index 0000000..e374d12 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/SmsProviderKind.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Module.Sms; + +/// +/// 短信服务商类型。 +/// +public enum SmsProviderKind +{ + /// + /// 腾讯云短信。 + /// + Tencent = 1, + + /// + /// 阿里云短信。 + /// + Aliyun = 2 +} diff --git a/src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj b/src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj new file mode 100644 index 0000000..7ac9fcd --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Sms/TakeoutSaaS.Module.Sms.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs new file mode 100644 index 0000000..c53009c --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs @@ -0,0 +1,36 @@ +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Module.Storage.Models; + +namespace TakeoutSaaS.Module.Storage.Abstractions; + +/// +/// 对象存储提供商统一抽象。 +/// +public interface IObjectStorageProvider +{ + /// + /// 当前提供商类型。 + /// + StorageProviderKind Kind { get; } + + /// + /// 上传文件到对象存储。 + /// + Task UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default); + + /// + /// 生成预签名直传参数(PUT 或表单直传)。 + /// + Task CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default); + + /// + /// 生成带过期时间的访问链接。 + /// + Task GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default); + + /// + /// 生成公共访问地址(可结合 CDN)。 + /// + string BuildPublicUrl(string objectKey); +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IStorageProviderResolver.cs b/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IStorageProviderResolver.cs new file mode 100644 index 0000000..63ae4cb --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IStorageProviderResolver.cs @@ -0,0 +1,14 @@ +namespace TakeoutSaaS.Module.Storage.Abstractions; + +/// +/// 存储提供商解析器,用于按需选择具体实现。 +/// +public interface IStorageProviderResolver +{ + /// + /// 根据配置解析出可用的存储提供商。 + /// + /// 目标提供商类型,空则使用默认配置。 + /// 对应的存储提供商。 + IObjectStorageProvider Resolve(StorageProviderKind? provider = null); +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Extensions/StorageServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Storage/Extensions/StorageServiceCollectionExtensions.cs new file mode 100644 index 0000000..e1972f1 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Extensions/StorageServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Module.Storage.Abstractions; +using TakeoutSaaS.Module.Storage.Options; +using TakeoutSaaS.Module.Storage.Providers; +using TakeoutSaaS.Module.Storage.Services; + +namespace TakeoutSaaS.Module.Storage.Extensions; + +/// +/// 存储模块服务注册扩展。 +/// +public static class StorageServiceCollectionExtensions +{ + /// + /// 注册存储模块所需的提供商与配置。 + /// + /// 服务集合。 + /// 配置源。 + public static IServiceCollection AddStorageModule(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("Storage")) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs new file mode 100644 index 0000000..80d6a81 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs @@ -0,0 +1,36 @@ +namespace TakeoutSaaS.Module.Storage.Models; + +/// +/// 直传(预签名上传)请求参数。 +/// +/// +/// 初始化请求。 +/// +/// 对象键。 +/// 内容类型。 +/// 内容长度。 +/// 签名有效期。 +public sealed class StorageDirectUploadRequest(string objectKey, string contentType, long contentLength, TimeSpan expires) +{ + + + /// + /// 目标对象键。 + /// + public string ObjectKey { get; } = objectKey; + + /// + /// 内容类型。 + /// + public string ContentType { get; } = contentType; + + /// + /// 内容长度。 + /// + public long ContentLength { get; } = contentLength; + + /// + /// 签名有效期。 + /// + public TimeSpan Expires { get; } = expires; +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs new file mode 100644 index 0000000..8bfade5 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace TakeoutSaaS.Module.Storage.Models; + +/// +/// 直传(预签名上传)结果。 +/// +public sealed class StorageDirectUploadResult +{ + /// + /// 预签名上传地址(PUT 上传或表单地址)。 + /// + public string UploadUrl { get; init; } = string.Empty; + + /// + /// 直传附加字段(如表单直传所需字段),PUT 方式为空。 + /// + public IReadOnlyDictionary FormFields { get; init; } = new Dictionary(); + + /// + /// 预签名过期时间。 + /// + public DateTimeOffset ExpiresAt { get; init; } + + /// + /// 关联的对象键。 + /// + public string ObjectKey { get; init; } = string.Empty; + + /// + /// 上传成功后可选的签名下载地址。 + /// + public string? SignedDownloadUrl { get; init; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs new file mode 100644 index 0000000..3d055ab --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.IO; + +namespace TakeoutSaaS.Module.Storage.Models; + +/// +/// 对象存储上传请求参数。 +/// +/// +/// 初始化上传请求。 +/// +/// 对象键(含路径)。 +/// 文件流。 +/// 内容类型。 +/// 内容长度。 +/// 是否返回签名访问链接。 +/// 签名有效期。 +/// 附加元数据。 +public sealed class StorageUploadRequest( + string objectKey, + Stream content, + string contentType, + long contentLength, + bool generateSignedUrl, + TimeSpan signedUrlExpires, + IDictionary? metadata = null) +{ + + /// + /// 对象键。 + /// + public string ObjectKey { get; } = objectKey; + + /// + /// 文件流。 + /// + public Stream Content { get; } = content; + + /// + /// 内容类型。 + /// + public string ContentType { get; } = contentType; + + /// + /// 内容长度。 + /// + public long ContentLength { get; } = contentLength; + + /// + /// 是否需要签名访问链接。 + /// + public bool GenerateSignedUrl { get; } = generateSignedUrl; + + /// + /// 签名有效期。 + /// + public TimeSpan SignedUrlExpires { get; } = signedUrlExpires; + + /// + /// 元数据集合。 + /// + public IReadOnlyDictionary Metadata { get; } = metadata == null + ? new Dictionary() + : new Dictionary(metadata); +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadResult.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadResult.cs new file mode 100644 index 0000000..c3af710 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadResult.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Module.Storage.Models; + +/// +/// 上传结果信息。 +/// +public sealed class StorageUploadResult +{ + /// + /// 对象键。 + /// + public string ObjectKey { get; init; } = string.Empty; + + /// + /// 可访问的 URL(可能已包含签名)。 + /// + public string Url { get; init; } = string.Empty; + + /// + /// 带过期时间的签名 URL(若生成)。 + /// + public string? SignedUrl { get; init; } + + /// + /// 文件大小。 + /// + public long FileSize { get; init; } + + /// + /// 内容类型。 + /// + public string ContentType { get; init; } = string.Empty; +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Options/AliyunOssOptions.cs b/src/Modules/TakeoutSaaS.Module.Storage/Options/AliyunOssOptions.cs new file mode 100644 index 0000000..d17f548 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Options/AliyunOssOptions.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Storage.Options; + +/// +/// 阿里云 OSS 访问配置。 +/// +public sealed class AliyunOssOptions +{ + /// + /// 访问密钥 ID。 + /// + [Required] + public string AccessKeyId { get; set; } = string.Empty; + + /// + /// 访问密钥 Secret。 + /// + [Required] + public string AccessKeySecret { get; set; } = string.Empty; + + /// + /// Endpoint,如 https://oss-cn-hangzhou.aliyuncs.com。 + /// + [Required] + [Url] + public string Endpoint { get; set; } = string.Empty; + + /// + /// 目标存储桶名称。 + /// + [Required] + public string Bucket { get; set; } = string.Empty; + + /// + /// CDN 加速域名(可选)。 + /// + [Url] + public string? CdnBaseUrl { get; set; } + + /// + /// 是否默认使用 HTTPS。 + /// + public bool UseHttps { get; set; } = true; +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Options/QiniuKodoOptions.cs b/src/Modules/TakeoutSaaS.Module.Storage/Options/QiniuKodoOptions.cs new file mode 100644 index 0000000..7e6bf37 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Options/QiniuKodoOptions.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Storage.Options; + +/// +/// 七牛云 Kodo S3 兼容网关配置。 +/// +public sealed class QiniuKodoOptions +{ + /// + /// AccessKey。 + /// + [Required] + public string AccessKey { get; set; } = string.Empty; + + /// + /// SecretKey。 + /// + [Required] + public string SecretKey { get; set; } = string.Empty; + + /// + /// 绑定的空间名称。 + /// + [Required] + public string Bucket { get; set; } = string.Empty; + + /// + /// 下载域名(CDN 域名或测试域名),用于生成访问链接。 + /// + [Url] + public string? DownloadDomain { get; set; } + + /// + /// S3 兼容网关 Endpoint(如 https://s3-cn-south-1.qiniucs.com),为空则使用官方默认。 + /// + [Url] + public string? Endpoint { get; set; } + + /// + /// 是否使用 HTTPS。 + /// + public bool UseHttps { get; set; } = true; + + /// + /// 直传或下载时默认有效期(分钟),未设置时使用全局安全配置。 + /// + [Range(1, 24 * 60)] + public int? SignedUrlExpirationMinutes { get; set; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Options/StorageOptions.cs b/src/Modules/TakeoutSaaS.Module.Storage/Options/StorageOptions.cs new file mode 100644 index 0000000..465b1b7 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Options/StorageOptions.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Storage.Options; + +/// +/// 存储模块的统一配置项,决定默认提供商与全局安全策略。 +/// +public sealed class StorageOptions +{ + /// + /// 默认使用的存储提供商。 + /// + public StorageProviderKind Provider { get; set; } = StorageProviderKind.TencentCos; + + /// + /// CDN 访问域名(可选),若配置则优先使用 CDN 域名生成访问地址。 + /// + [Url] + public string? CdnBaseUrl { get; set; } + + /// + /// 腾讯云 COS 配置。 + /// + [Required] + public TencentCosOptions TencentCos { get; set; } = new(); + + /// + /// 七牛云 Kodo 配置。 + /// + [Required] + public QiniuKodoOptions QiniuKodo { get; set; } = new(); + + /// + /// 阿里云 OSS 配置。 + /// + [Required] + public AliyunOssOptions AliyunOss { get; set; } = new(); + + /// + /// 存储安全策略配置。 + /// + [Required] + public StorageSecurityOptions Security { get; set; } = new(); +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Options/StorageSecurityOptions.cs b/src/Modules/TakeoutSaaS.Module.Storage/Options/StorageSecurityOptions.cs new file mode 100644 index 0000000..0619faf --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Options/StorageSecurityOptions.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Storage.Options; + +/// +/// 文件安全与防盗链相关配置。 +/// +public sealed class StorageSecurityOptions +{ + /// + /// 单个文件最大尺寸(字节),默认 10MB。 + /// + [Range(1, long.MaxValue)] + public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024; + + /// + /// 允许的图片后缀名白名单。 + /// + [MinLength(1)] + public string[] AllowedImageExtensions { get; set; } = { ".jpg", ".jpeg", ".png", ".webp", ".gif" }; + + /// + /// 允许的通用文件后缀名白名单。 + /// + [MinLength(1)] + public string[] AllowedFileExtensions { get; set; } = { ".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf" }; + + /// + /// 默认签名有效期(分钟),用于生成带过期时间的访问链接。 + /// + [Range(1, 24 * 60)] + public int DefaultUrlExpirationMinutes { get; set; } = 30; + + /// + /// 是否启用来源校验(防盗链),为空则不校验。 + /// + public bool EnableRefererValidation { get; set; } = true; + + /// + /// 允许的 Referer/Origin 前缀列表,用于限制上传接口调用来源。 + /// + public string[] AllowedReferers { get; set; } = Array.Empty(); + + /// + /// 针对 CDN 防盗链的额外签名密钥(可选),用于生成二次校验签名。 + /// + public string? AntiLeechTokenSecret { get; set; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Options/TencentCosOptions.cs b/src/Modules/TakeoutSaaS.Module.Storage/Options/TencentCosOptions.cs new file mode 100644 index 0000000..0808821 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Options/TencentCosOptions.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Module.Storage.Options; + +/// +/// 腾讯云 COS 访问配置。 +/// +public sealed class TencentCosOptions +{ + /// + /// SecretId。 + /// + [Required] + public string SecretId { get; set; } = string.Empty; + + /// + /// SecretKey。 + /// + [Required] + public string SecretKey { get; set; } = string.Empty; + + /// + /// 存储地域(如 ap-guangzhou)。 + /// + [Required] + public string Region { get; set; } = string.Empty; + + /// + /// 存储桶名称(含 AppId,如 takeout-bucket-123456)。 + /// + [Required] + public string Bucket { get; set; } = string.Empty; + + /// + /// COS 自定义域名或 API Endpoint(可选),未配置则根据 Region 生成默认域名。 + /// + public string? Endpoint { get; set; } + + /// + /// CDN 域名(可选),用于生成加速访问地址。 + /// + [Url] + public string? CdnBaseUrl { get; set; } + + /// + /// 是否使用 HTTPS。 + /// + public bool UseHttps { get; set; } = true; + + /// + /// 是否强制使用 PathStyle 访问,COS 默认可使用虚拟主机形式。 + /// + public bool ForcePathStyle { get; set; } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs new file mode 100644 index 0000000..abb74ed --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Aliyun.OSS; +using Aliyun.OSS.Util; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Storage.Abstractions; +using TakeoutSaaS.Module.Storage.Models; +using TakeoutSaaS.Module.Storage.Options; + +namespace TakeoutSaaS.Module.Storage.Providers; + +/// +/// 阿里云 OSS 存储提供商实现。 +/// +public sealed class AliyunOssStorageProvider(IOptionsMonitor optionsMonitor) : IObjectStorageProvider, IDisposable +{ + private OssClient? _client; + private bool _disposed; + + private StorageOptions CurrentOptions => optionsMonitor.CurrentValue; + + /// + public StorageProviderKind Kind => StorageProviderKind.AliyunOss; + + /// + public async Task UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default) + { + var options = CurrentOptions; + var metadata = new ObjectMetadata + { + ContentLength = request.ContentLength, + ContentType = request.ContentType + }; + + foreach (var kv in request.Metadata) + { + metadata.UserMetadata[kv.Key] = kv.Value; + } + + // Aliyun OSS SDK 支持异步方法,如未支持将同步封装为任务。 + await PutObjectAsync(options.AliyunOss.Bucket, request.ObjectKey, request.Content, metadata, cancellationToken) + .ConfigureAwait(false); + + var signedUrl = request.GenerateSignedUrl + ? await GenerateDownloadUrlAsync(request.ObjectKey, request.SignedUrlExpires, cancellationToken).ConfigureAwait(false) + : null; + + return new StorageUploadResult + { + ObjectKey = request.ObjectKey, + Url = signedUrl ?? BuildPublicUrl(request.ObjectKey), + SignedUrl = signedUrl, + FileSize = request.ContentLength, + ContentType = request.ContentType + }; + } + + /// + public Task CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default) + { + var expiresAt = DateTimeOffset.UtcNow.Add(request.Expires); + var uploadUrl = GeneratePresignedUrl(request.ObjectKey, request.Expires, SignHttpMethod.Put, request.ContentType); + var downloadUrl = GeneratePresignedUrl(request.ObjectKey, request.Expires, SignHttpMethod.Get, null); + + var result = new StorageDirectUploadResult + { + UploadUrl = uploadUrl, + FormFields = new Dictionary(), + ExpiresAt = expiresAt, + ObjectKey = request.ObjectKey, + SignedDownloadUrl = downloadUrl + }; + + return Task.FromResult(result); + } + + /// + public Task GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default) + { + var url = GeneratePresignedUrl(objectKey, expires, SignHttpMethod.Get, null); + return Task.FromResult(url); + } + + /// + public string BuildPublicUrl(string objectKey) + { + var cdn = CurrentOptions.AliyunOss.CdnBaseUrl ?? CurrentOptions.CdnBaseUrl; + if (!string.IsNullOrWhiteSpace(cdn)) + { + return $"{cdn!.TrimEnd('/')}/{objectKey}"; + } + + var endpoint = CurrentOptions.AliyunOss.Endpoint.TrimEnd('/'); + var scheme = CurrentOptions.AliyunOss.UseHttps ? "https" : "http"; + // Endpoint 可能已包含协议,若没有则补充。 + if (!endpoint.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + { + endpoint = $"{scheme}://{endpoint}"; + } + + return $"{endpoint}/{CurrentOptions.AliyunOss.Bucket}/{objectKey}"; + } + + /// + /// 上传对象到 OSS。 + /// + private async Task PutObjectAsync(string bucket, string key, Stream content, ObjectMetadata metadata, CancellationToken cancellationToken) + { + var client = EnsureClient(); + await Task.Run(() => client.PutObject(bucket, key, content, metadata), cancellationToken).ConfigureAwait(false); + } + + /// + /// 生成预签名 URL。 + /// + private string GeneratePresignedUrl(string objectKey, TimeSpan expires, SignHttpMethod method, string? contentType) + { + var request = new GeneratePresignedUriRequest(CurrentOptions.AliyunOss.Bucket, objectKey, method) + { + Expiration = DateTime.Now.Add(expires) + }; + + if (!string.IsNullOrWhiteSpace(contentType)) + { + request.ContentType = contentType; + } + + var uri = EnsureClient().GeneratePresignedUri(request); + return uri.ToString(); + } + + /// + /// 构建或复用 OSS 客户端。 + /// + private OssClient EnsureClient() + { + if (_client != null) + { + return _client; + } + + var options = CurrentOptions.AliyunOss; + _client = new OssClient(options.Endpoint, options.AccessKeyId, options.AccessKeySecret); + return _client; + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs new file mode 100644 index 0000000..df6b05d --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Storage.Options; + +namespace TakeoutSaaS.Module.Storage.Providers; + +/// +/// 七牛云 Kodo(S3 兼容网关)存储提供商。 +/// +public sealed class QiniuKodoStorageProvider(IOptionsMonitor optionsMonitor) + : S3StorageProviderBase +{ + private StorageOptions CurrentOptions => optionsMonitor.CurrentValue; + + /// + public override StorageProviderKind Kind => StorageProviderKind.QiniuKodo; + + /// + protected override string Bucket => CurrentOptions.QiniuKodo.Bucket; + + /// + protected override string ServiceUrl => string.IsNullOrWhiteSpace(CurrentOptions.QiniuKodo.Endpoint) + ? $"{(CurrentOptions.QiniuKodo.UseHttps ? "https" : "http")}://s3.qiniucs.com" + : CurrentOptions.QiniuKodo.Endpoint!; + + /// + protected override string AccessKey => CurrentOptions.QiniuKodo.AccessKey; + + /// + protected override string SecretKey => CurrentOptions.QiniuKodo.SecretKey; + + /// + protected override bool UseHttps => CurrentOptions.QiniuKodo.UseHttps; + + /// + protected override bool ForcePathStyle => true; + + /// + protected override string? CdnBaseUrl => !string.IsNullOrWhiteSpace(CurrentOptions.QiniuKodo.DownloadDomain) + ? CurrentOptions.QiniuKodo.DownloadDomain + : CurrentOptions.CdnBaseUrl; + + /// + protected override TimeSpan SignedUrlExpiry + { + get + { + var minutes = CurrentOptions.QiniuKodo.SignedUrlExpirationMinutes + ?? CurrentOptions.Security.DefaultUrlExpirationMinutes; + return TimeSpan.FromMinutes(Math.Max(1, minutes)); + } + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs new file mode 100644 index 0000000..afdfe96 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime; +using Amazon.S3; +using Amazon.S3.Model; +using TakeoutSaaS.Module.Storage.Abstractions; +using TakeoutSaaS.Module.Storage.Models; + +namespace TakeoutSaaS.Module.Storage.Providers; + +/// +/// 基于 AWS S3 SDK 的通用存储提供商基类,可复用到 COS 与 Kodo 等兼容实现。 +/// +public abstract class S3StorageProviderBase : IObjectStorageProvider, IDisposable +{ + private IAmazonS3? _client; + private bool _disposed; + + /// + public abstract StorageProviderKind Kind { get; } + + /// + /// 目标桶名称。 + /// + protected abstract string Bucket { get; } + + /// + /// S3 服务端点,需包含协议。 + /// + protected abstract string ServiceUrl { get; } + + /// + /// 访问凭证 ID。 + /// + protected abstract string AccessKey { get; } + + /// + /// 访问凭证密钥。 + /// + protected abstract string SecretKey { get; } + + /// + /// 是否使用 HTTPS。 + /// + protected abstract bool UseHttps { get; } + + /// + /// 是否强制 PathStyle 访问。 + /// + protected abstract bool ForcePathStyle { get; } + + /// + /// CDN 域名(可选)。 + /// + protected abstract string? CdnBaseUrl { get; } + + /// + /// 默认签名有效期。 + /// + protected abstract TimeSpan SignedUrlExpiry { get; } + + /// + public virtual async Task UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default) + { + var putRequest = new PutObjectRequest + { + BucketName = Bucket, + Key = request.ObjectKey, + InputStream = request.Content, + AutoCloseStream = false, + ContentType = request.ContentType + }; + + foreach (var kv in request.Metadata) + { + putRequest.Metadata[kv.Key] = kv.Value; + } + + await Client.PutObjectAsync(putRequest, cancellationToken).ConfigureAwait(false); + + var signedUrl = request.GenerateSignedUrl + ? GenerateSignedUrl(request.ObjectKey, request.SignedUrlExpires) + : null; + + return new StorageUploadResult + { + ObjectKey = request.ObjectKey, + Url = signedUrl ?? BuildPublicUrl(request.ObjectKey), + SignedUrl = signedUrl, + FileSize = request.ContentLength, + ContentType = request.ContentType + }; + } + + /// + public virtual Task CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default) + { + var expiresAt = DateTimeOffset.UtcNow.Add(request.Expires); + var uploadUrl = GenerateSignedUrl(request.ObjectKey, request.Expires, HttpVerb.PUT, request.ContentType); + var signedDownload = GenerateSignedUrl(request.ObjectKey, request.Expires); + + var result = new StorageDirectUploadResult + { + UploadUrl = uploadUrl, + FormFields = new Dictionary(), + ExpiresAt = expiresAt, + ObjectKey = request.ObjectKey, + SignedDownloadUrl = signedDownload + }; + + return Task.FromResult(result); + } + + /// + public virtual Task GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default) + { + var url = GenerateSignedUrl(objectKey, expires); + return Task.FromResult(url); + } + + /// + public virtual string BuildPublicUrl(string objectKey) + { + if (!string.IsNullOrWhiteSpace(CdnBaseUrl)) + { + return $"{CdnBaseUrl!.TrimEnd('/')}/{objectKey}"; + } + + var endpoint = new Uri(ServiceUrl); + var scheme = UseHttps ? "https" : "http"; + return $"{scheme}://{Bucket}.{endpoint.Host}/{objectKey}"; + } + + /// + /// 生成预签名 URL。 + /// + /// 对象键。 + /// 过期时间。 + /// HTTP 动作。 + /// 可选的内容类型约束。 + protected virtual string GenerateSignedUrl(string objectKey, TimeSpan expires, HttpVerb verb = HttpVerb.GET, string? contentType = null) + { + var request = new GetPreSignedUrlRequest + { + BucketName = Bucket, + Key = objectKey, + Verb = verb, + Expires = DateTime.UtcNow.Add(expires), + Protocol = UseHttps ? Protocol.HTTPS : Protocol.HTTP + }; + + if (!string.IsNullOrWhiteSpace(contentType)) + { + request.Headers["Content-Type"] = contentType; + } + + return Client.GetPreSignedURL(request); + } + + /// + /// 创建 S3 客户端。 + /// + protected virtual IAmazonS3 CreateClient() + { + var config = new AmazonS3Config + { + ServiceURL = ServiceUrl, + ForcePathStyle = ForcePathStyle, + UseHttp = !UseHttps + }; + + var credentials = new BasicAWSCredentials(AccessKey, SecretKey); + return new AmazonS3Client(credentials, config); + } + + private IAmazonS3 Client => _client ??= CreateClient(); + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _client?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs new file mode 100644 index 0000000..fdd7795 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs @@ -0,0 +1,46 @@ +using System; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Storage.Options; + +namespace TakeoutSaaS.Module.Storage.Providers; + +/// +/// 腾讯云 COS 存储提供商实现。 +/// +public sealed class TencentCosStorageProvider(IOptionsMonitor optionsMonitor) + : S3StorageProviderBase +{ + private StorageOptions CurrentOptions => optionsMonitor.CurrentValue; + + /// + public override StorageProviderKind Kind => StorageProviderKind.TencentCos; + + /// + protected override string Bucket => CurrentOptions.TencentCos.Bucket; + + /// + protected override string ServiceUrl => string.IsNullOrWhiteSpace(CurrentOptions.TencentCos.Endpoint) + ? $"{(CurrentOptions.TencentCos.UseHttps ? "https" : "http")}://cos.{CurrentOptions.TencentCos.Region}.myqcloud.com" + : CurrentOptions.TencentCos.Endpoint!; + + /// + protected override string AccessKey => CurrentOptions.TencentCos.SecretId; + + /// + protected override string SecretKey => CurrentOptions.TencentCos.SecretKey; + + /// + protected override bool UseHttps => CurrentOptions.TencentCos.UseHttps; + + /// + protected override bool ForcePathStyle => CurrentOptions.TencentCos.ForcePathStyle; + + /// + protected override string? CdnBaseUrl => !string.IsNullOrWhiteSpace(CurrentOptions.TencentCos.CdnBaseUrl) + ? CurrentOptions.TencentCos.CdnBaseUrl + : CurrentOptions.CdnBaseUrl; + + /// + protected override TimeSpan SignedUrlExpiry => + TimeSpan.FromMinutes(Math.Max(1, CurrentOptions.Security.DefaultUrlExpirationMinutes)); +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs b/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs new file mode 100644 index 0000000..598d1ee --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Module.Storage.Abstractions; +using TakeoutSaaS.Module.Storage.Options; + +namespace TakeoutSaaS.Module.Storage.Services; + +/// +/// 存储提供商解析器,实现基于配置的提供商选择。 +/// +public sealed class StorageProviderResolver(IOptionsMonitor optionsMonitor, IEnumerable providers) + : IStorageProviderResolver +{ + private readonly IDictionary _providerMap = + providers.ToDictionary(x => x.Kind, x => x); + + /// + public IObjectStorageProvider Resolve(StorageProviderKind? provider = null) + { + var target = provider ?? optionsMonitor.CurrentValue.Provider; + if (_providerMap.TryGetValue(target, out var instance)) + { + return instance; + } + + throw new InvalidOperationException($"未注册存储提供商:{target}"); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/StorageProviderKind.cs b/src/Modules/TakeoutSaaS.Module.Storage/StorageProviderKind.cs new file mode 100644 index 0000000..589b9f8 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Storage/StorageProviderKind.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Module.Storage; + +/// +/// 存储提供商类型枚举,便于通过配置选择具体的对象存储实现。 +/// +public enum StorageProviderKind +{ + /// + /// 腾讯云 COS 对象存储。 + /// + TencentCos = 1, + + /// + /// 七牛云 Kodo 存储。 + /// + QiniuKodo = 2, + + /// + /// 阿里云 OSS 存储。 + /// + AliyunOss = 3 +} diff --git a/src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj b/src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj new file mode 100644 index 0000000..b8007bc Binary files /dev/null and b/src/Modules/TakeoutSaaS.Module.Storage/TakeoutSaaS.Module.Storage.csproj differ diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs new file mode 100644 index 0000000..72afde3 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Module.Tenancy.Extensions; + +/// +/// 多租户服务注册及中间件扩展。 +/// +public static class TenantServiceCollectionExtensions +{ + /// + /// 注册租户上下文、解析中间件及默认租户提供者。 + /// + public static IServiceCollection AddTenantResolution(this IServiceCollection services, IConfiguration configuration) + { + services.TryAddSingleton(); + services.TryAddScoped(); + + services.AddOptions() + .Bind(configuration.GetSection("Tenancy")) + .ValidateDataAnnotations(); + + return services; + } + + /// + /// 使用多租户解析中间件。 + /// + public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder app) + => app.UseMiddleware(); +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj b/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj new file mode 100644 index 0000000..2617cb0 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + + + + + + + + diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs new file mode 100644 index 0000000..2a7f7ab --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs @@ -0,0 +1,34 @@ +using System.Threading; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 基于 的租户上下文访问器,实现请求级别隔离。 +/// +public sealed class TenantContextAccessor : ITenantContextAccessor +{ + private static readonly AsyncLocal Holder = new(); + + /// + public TenantContext? Current + { + get => Holder.Value?.Context; + set + { + if (Holder.Value != null) + { + Holder.Value.Context = value; + } + else if (value != null) + { + Holder.Value = new TenantContextHolder { Context = value }; + } + } + } + + private sealed class TenantContextHolder + { + public TenantContext? Context { get; set; } + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs new file mode 100644 index 0000000..069202d --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs @@ -0,0 +1,19 @@ +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 默认租户提供者:基于租户上下文访问器暴露当前租户 ID。 +/// +/// +/// 初始化租户提供者。 +/// +/// 租户上下文访问器 +public sealed class TenantProvider(ITenantContextAccessor tenantContextAccessor) : ITenantProvider +{ + + + /// + public long GetCurrentTenantId() + => tenantContextAccessor.Current?.TenantId ?? 0; +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs new file mode 100644 index 0000000..2a117bf --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs @@ -0,0 +1,180 @@ +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 多租户解析中间件:支持 Header、域名与 Token Claim 的优先级解析。 +/// +/// +/// 初始化中间件。 +/// +public sealed class TenantResolutionMiddleware( + RequestDelegate next, + ILogger logger, + ITenantContextAccessor tenantContextAccessor, + IOptionsMonitor optionsMonitor) +{ + + + /// + /// 解析租户并将上下文注入请求。 + /// + public async Task InvokeAsync(HttpContext context) + { + var options = optionsMonitor.CurrentValue ?? new TenantResolutionOptions(); + if (ShouldSkip(context.Request.Path, options)) + { + await next(context); + return; + } + + var tenantContext = ResolveTenant(context, options); + tenantContextAccessor.Current = tenantContext; + context.Items[TenantConstants.HttpContextItemKey] = tenantContext; + + if (!tenantContext.IsResolved) + { + logger.LogDebug("未能解析租户:{Path}", context.Request.Path); + + if (options.ThrowIfUnresolved) + { + var response = ApiResponse.Error(ErrorCodes.BadRequest, "缺少租户标识"); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(response, cancellationToken: context.RequestAborted); + tenantContextAccessor.Current = null; + context.Items.Remove(TenantConstants.HttpContextItemKey); + return; + } + } + + try + { + await next(context); + } + finally + { + tenantContextAccessor.Current = null; + context.Items.Remove(TenantConstants.HttpContextItemKey); + } + } + + private static bool ShouldSkip(PathString path, TenantResolutionOptions options) + { + if (!path.HasValue) + { + return false; + } + + var value = path.Value ?? string.Empty; + if (options.IgnoredPaths.Contains(value)) + { + return true; + } + + return options.IgnoredPaths.Any(ignore => + { + if (string.IsNullOrWhiteSpace(ignore)) + { + return false; + } + + var ignorePath = new PathString(ignore); + return path.StartsWithSegments(ignorePath); + }); + } + + private static TenantContext ResolveTenant(HttpContext context, TenantResolutionOptions options) + { + var request = context.Request; + + // 1. Header 中的租户 ID + if (!string.IsNullOrWhiteSpace(options.TenantIdHeaderName) && + request.Headers.TryGetValue(options.TenantIdHeaderName, out var tenantHeader) && + long.TryParse(tenantHeader.FirstOrDefault(), out var headerTenantId)) + { + return new TenantContext(headerTenantId, null, $"header:{options.TenantIdHeaderName}"); + } + + // 2. Header 中的租户编码 + if (!string.IsNullOrWhiteSpace(options.TenantCodeHeaderName) && + request.Headers.TryGetValue(options.TenantCodeHeaderName, out var codeHeader)) + { + var code = codeHeader.FirstOrDefault(); + if (TryResolveByCode(code, options, out var tenantFromCode)) + { + return new TenantContext(tenantFromCode, code, $"header:{options.TenantCodeHeaderName}"); + } + } + + // 3. Host 映射/子域名解析 + var host = request.Host.Host; + if (!string.IsNullOrWhiteSpace(host)) + { + if (options.DomainTenantMap.TryGetValue(host, out var tenantFromHost)) + { + return new TenantContext(tenantFromHost, null, $"host:{host}"); + } + + var codeFromHost = ResolveCodeFromHost(host, options.RootDomain); + if (TryResolveByCode(codeFromHost, options, out var tenantFromSubdomain)) + { + return new TenantContext(tenantFromSubdomain, codeFromHost, $"host:{host}"); + } + } + + // 4. Token Claim + var claim = context.User?.FindFirst("tenant_id"); + if (claim != null && long.TryParse(claim.Value, out var claimTenant)) + { + return new TenantContext(claimTenant, null, "claim:tenant_id"); + } + + return TenantContext.Empty; + } + + private static bool TryResolveByCode(string? code, TenantResolutionOptions options, out long tenantId) + { + tenantId = 0; + if (string.IsNullOrWhiteSpace(code)) + { + return false; + } + + return options.CodeTenantMap.TryGetValue(code, out tenantId); + } + + private static string? ResolveCodeFromHost(string host, string? rootDomain) + { + if (string.IsNullOrWhiteSpace(rootDomain)) + { + return null; + } + + var normalizedRoot = rootDomain.TrimStart('.'); + if (!host.EndsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var suffixLength = normalizedRoot.Length; + if (host.Length <= suffixLength) + { + return null; + } + + var withoutRoot = host[..(host.Length - suffixLength)]; + if (withoutRoot.EndsWith('.')) + { + withoutRoot = withoutRoot[..^1]; + } + + var segments = withoutRoot.Split('.', StringSplitOptions.RemoveEmptyEntries); + return segments.Length == 0 ? null : segments[0]; + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs new file mode 100644 index 0000000..7cd928d --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs @@ -0,0 +1,56 @@ +using System.Collections.ObjectModel; + +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 多租户解析配置项。 +/// +public sealed class TenantResolutionOptions +{ + /// + /// 通过 Header 解析租户 ID 时使用的头名称,默认 X-Tenant-Id。 + /// + public string TenantIdHeaderName { get; set; } = "X-Tenant-Id"; + + /// + /// 通过 Header 解析租户编码时使用的头名称,默认 X-Tenant-Code。 + /// + public string TenantCodeHeaderName { get; set; } = "X-Tenant-Code"; + + /// + /// 明确指定 host 与租户 ID 对应关系的映射表(精确匹配)。 + /// + public IDictionary DomainTenantMap { get; set; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// 租户编码到租户 ID 的映射表,用于 header 或子域名解析。 + /// + public IDictionary CodeTenantMap { get; set; } + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// 根域(不含子域),用于形如 {tenant}.rootDomain 的场景,例如 admin.takeoutsaas.com。 + /// + public string? RootDomain { get; set; } + + /// + /// 需要跳过租户解析的路径集合(如健康检查),默认仅包含 /health。 + /// + public ISet IgnoredPaths { get; } = new HashSet(StringComparer.OrdinalIgnoreCase) { "/health" }; + + /// + /// 若为 true,当无法解析租户时立即返回 400;否则交由上层自行判定。 + /// + public bool ThrowIfUnresolved { get; set; } + + /// + /// 对外只读视图,便于审计日志输出。 + /// + public IReadOnlyDictionary DomainMappings => new ReadOnlyDictionary(DomainTenantMap); + + /// + /// 对外只读的编码映射。 + /// + public IReadOnlyDictionary CodeMappings => new ReadOnlyDictionary(CodeTenantMap); +}