diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6c8b717 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# EditorConfig +root = true + +[*.cs] +dotnet_diagnostic.SA1600.severity = error +dotnet_diagnostic.SA1601.severity = error +dotnet_diagnostic.SA1615.severity = error +dotnet_diagnostic.SA1629.severity = none +dotnet_diagnostic.SA1202.severity = none +dotnet_diagnostic.SA1200.severity = none +dotnet_diagnostic.SA1623.severity = none +dotnet_diagnostic.SA1111.severity = none +dotnet_diagnostic.SA1101.severity = none diff --git a/.gitignore b/.gitignore index 3857e65..16baddd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ bin/ obj/ **/bin/ **/obj/ +.claude/ diff --git a/AGENTS.md b/AGENTS.md index 1ed8c30..0990858 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,11 +47,21 @@ ## 4. 注释与文档 * **强制 XML 注释**:所有 `public` 的类、方法、属性必须有 ``。 -* **步骤注释**:超过 5 行的业务逻辑,必须分步注释: - ```csharp - // 1. 验证库存 - // 2. 扣减余额 - ``` +* **分段逻辑注释 (强制)**: + * **空行必注**:代码中每当出现空行分隔逻辑块时,**必须**在空行后的第一行添加 `//` 注释,简要说明紧接着这段代码的意图或作用。 + * **步骤化**:对于稍微复杂的业务逻辑,必须结合序号(1., 2., ...)进行标记。 + * **示例**: + ```csharp + // 1. 验证用户是否存在 + var user = await _repo.GetAsync(id); + if (user == null) return NotFound(); + + // 2. (空行后) 扣减余额逻辑 + user.Balance -= amount; + + // 3. (空行后) 保存更改 + await _unitOfWork.SaveChangesAsync(); + ``` * **Swagger**:必须开启 JWT 鉴权按钮,Request/Response 示例必须清晰。 ## 5. 异常处理 (防御性编程) @@ -142,50 +152,64 @@ 5. [ ] **性能陷阱**:是否在循环中查询了数据库 (N+1)? 6. [ ] **精度丢失**:Long 类型的 ID 是否转为了 String? 7. [ ] **配置硬编码**:是否直接写死了连接串或密钥? +8. [ ] **文档注释**:是否给所有 `public` 类/方法/属性添加了 ``? +9. [ ] **逻辑注释**:是否在每个空行分隔的逻辑块上方添加了说明注释? -## 17. .NET 10 / C# 14 现代语法最佳实践(增量) -> 2025 年推荐的 20 条语法规范,新增特性优先,保持极简。 -1. **field 关键字**:属性内直接使用 `field` 处理后备字段,`set => field = value.Trim();`。 -2. **空值条件赋值 `?.=`**:仅对象非空时赋值,减少 `if`。 -3. **未绑定泛型 nameof**:`nameof(List<>)` 获取泛型类型名,无需占位类型参数。 -4. **Lambda 参数修饰符**:在 Lambda 中可用 `ref/out/in` 与默认参数,例如 `(ref int x, int bonus = 10) => x += bonus;`。 -5. **主构造函数 (Primary Constructor)**:服务/数据类优先 `class Foo(IDep dep, ILogger logger) { }`。 -6. **record/required/init**:DTO 默认用 record;关键属性用 `required`;不可变属性用 `init`。 -7. **集合表达式与展开**:使用 `[]` 创建集合,`[..other]` 拼接,`str[1..^1]` 进行切片。 -8. **模式匹配**:列表模式 `[1, 2, .. var rest]`、属性模式 `{ IsActive: true }`、switch 表达式简化分支。 -9. **文件范围命名空间/全局 using**:减少缩进与重复引用;复杂泛型用别名。 -10. **顶级语句**:Program.cs 保持顶级语句风格。 -11. **原始/UTF-8 字面量**:多行文本用 `"""`,性能场景用 `"text"u8`。 -12. **不可变命令优先**:命令/DTO 优先用 record 和 `with` 非破坏性拷贝,例如 `command = command with { MerchantId = merchantId };`,避免直接 `command.Property = ...` 带来的副作用。 +## 17. 现代语法范式 (.NET 10 / C# 14) +> **原则**:拥抱新特性以减少样板代码,但严禁牺牲可读性。 -(其余规则继续遵循上文约束:分层、命名、异步、日志、验证、租户/ID 策略等。) +1. **主构造函数 (Primary Constructor)**: + * **强制**:依赖注入场景必须使用 `class Service(IDep dep) { }`。 + * **禁止**:在主构造函数类中显式定义 `private readonly` 字段来承接参数(直接使用参数即可)。 +2. **对象初始化与不可变性**: + * **DTO/Command**:必须使用 `record` 类型。 + * **属性定义**:默认使用 `init`;必填项加 `required`;逻辑变更使用 `with` 表达式。 +3. **集合表达式**: + * **统一**:使用 `[]` 初始化集合。 + * **拼接**:使用 `[..array1, ..array2]` 替代 `Concat`。 +4. **模式匹配 (Pattern Matching)**: + * **替代**:严禁复杂的 `if-else if` 链,强制使用 `switch` 表达式。 + * **判空**:使用 `is not null` 替代 `!= null`。 +5. **极简语法糖**: + * **Field 关键字**:属性后备字段必须使用 `field` 关键字(如 `set => field = value.Trim();`)。 + * **空值赋值**:使用 `?.=` 简化判空赋值逻辑。 + * **字符串**:多行文本强制用 `"""` (Raw String Literal)。 -## 18. .NET 10 极致性能优化最佳实践(增量) -> 侧重零分配、并发与底层优化,遵循 2025 推荐方案。 -1. **Span/ReadOnlySpan 优先**:API 参数尽量用 `ReadOnlySpan` 处理字符串/切片,避免 Substring/复制。 -2. **栈分配与数组池**:小缓冲用 `stackalloc`,大缓冲统一用 `ArrayPool.Shared`,禁止直接 `new` 大数组。 -3. **UTF-8 字面量**:常量字节使用 `"text"u8`,避免运行时编码。 -4. **避免装箱**:热点路径规避隐式装箱,必要时用 `ref struct` 约束栈分配。 -5. **Frozen 集合**:只读查找表用 `FrozenDictionary/FrozenSet`,初始化后不再修改。 -6. **SearchValues SIMD 查找**:Span 内多字符搜索用 `SearchValues.Create(...)` + `ContainsAny`。 -7. **预设集合容量**:`List/Dictionary` 预知规模必须指定 `Capacity`。 -8. **ValueTask 热点返回**:可能同步完成的异步返回 `ValueTask`,减少 Task 分配。 -9. **Parallel.ForEachAsync 控并发**:I/O 并发用 Parallel.ForEachAsync 控制并行度,替代粗暴 Task.WhenAll。 -10. **避免 Task.Run**:在 ASP.NET Core 请求中不使用 Task.Run 做后台工作,改用 IHostedService 或 Channel 模式。 -11. **Channel 代替锁**:多线程数据传递优先使用 Channels,实现无锁生产者-消费者。 -12. **NativeAOT/PGO/向量化**:微服务/工具开启 NativeAOT;保留动态 PGO;计算密集场景考虑 System.Runtime.Intrinsics。 -13. **System.Text.Json + 源生成器**:全面替换 Newtonsoft.Json;使用 `[JsonSerializable]` + 生成的 `JsonSerializerContext`,兼容 NativeAOT,零反射。 -14. **Pipelines 处理流**:TCP/文件流解析使用 `PipeReader/PipeWriter`,获得零拷贝与缓冲管理。 -15. **HybridCache**:内存+分布式缓存统一用 HybridCache,利用防击穿合并并发请求。 +## 18. 极致性能规约 (High Performance) +> **原则**:默认编写“零分配 (Zero-Allocation)”代码,热点路径拒绝 GC 压力。 -## 19. 架构优化(增量) -> 架构优化方案 -1. **Chiseled 容器优先**:生产镜像基于 `mcr.microsoft.com/dotnet/runtime-deps:10.0-jammy-chiseled`,无 Shell、非 root,缩小攻击面,符合零信任要求。 -2. **默认集成 OpenTelemetry**:架构内置 OTel,统一通过 OTLP 导出 Metrics/Traces/Logs,避免依赖专有 APM 探针。 -3. **内部同步调用首选 gRPC**:微服务间禁止 JSON over HTTP,同步调用统一使用 gRPC,配合 Protobuf 源生成器获取强类型契约与更小载荷。 -4. **Outbox 模式强制**:处理领域事件时,事件记录必须与业务数据同事务写入 Outbox 表;后台 Worker 轮询 Outbox 再推送 MQ(RabbitMQ/Kafka),禁止事务提交后直接发消息以避免不一致。 -5. **共享资源必加分布式锁**:涉及库存扣减、定时任务抢占等共享资源时,必须引入分布式锁(如 Redis RedLock),防止并发竞争与脏写。 +1. **内存管理**: + * **字符串处理**:API 参数解析层**强制**使用 `ReadOnlySpan`,严禁使用 `Substring`。 + * **数组分配**:小块内存 (<1KB) 使用 `stackalloc`;大块内存 (>1KB) 必须使用 `ArrayPool.Shared`。 +2. **并发模型**: + * **无锁编程**:进程内生产者-消费者模型**必须**使用 `System.Threading.Channels`,严禁使用 `lock` 或 `BlockingCollection`。 + * **并行控制**:I/O 密集型并发必须使用 `Parallel.ForEachAsync` 并配置 `MaxDegreeOfParallelism`。 + * **异步返回**:热点路径下,若可能同步完成,返回类型必须为 `ValueTask`。 +3. **序列化与查找**: + * **JSON**:**全线废弃** Newtonsoft.Json。必须使用 `System.Text.Json` 配合源生成器 (`[JsonSerializable]`) 以支持 NativeAOT。 + * **静态集合**:只读字典/集合**必须**使用 `FrozenDictionary` / `FrozenSet`。 + * **SIMD 搜索**:多字符匹配场景使用 `SearchValues` 替代 `IndexOfAny`。 +4. **缓存架构**: + * **统一入口**:必须使用 `HybridCache` (或类似多级缓存抽象),禁止直接操作 `IDistributedCache` 以避免缓存击穿。 +## 19. 云原生架构规范 (Architecture) +> **原则**:默认零信任,默认分布式,默认可观测。 + +1. **容器与部署**: + * **基底镜像**:生产环境**强制**使用 Chiseled 镜像 (`runtime-deps:10.0-jammy-chiseled`),无 Shell、无 Root,最大化安全。 + * **健康检查**:必须包含 Liveness (存活) 和 Readiness (就绪) 探针。 +2. **服务间通信**: + * **同步调用**:内部微服务间**严禁**使用 REST/JSON,**必须**使用 gRPC (Protobuf)。 + * **契约管理**:Proto 文件必须作为单一事实来源 (Single Source of Truth) 统一管理。 +3. **数据一致性 (关键)**: + * **Outbox 模式**:领域事件发布**严禁**直接调用 MQ。必须在同一数据库事务中写入 `Outbox` 表,由独立 Worker 异步推送。 + * **幂等性**:所有消费者 (Consumer) 必须实现基于 `MessageId` 的幂等处理逻辑。 +4. **可观测性 (Observability)**: + * **OpenTelemetry**:严禁依赖特定厂商 SDK。必须统一输出 OTLP 标准格式 (Metrics/Logs/Traces)。 + * **关联ID**:确保 `TraceId` 在 HTTP Headers 和 MQ Metadata 中全程透传。 +5. **并发控制**: + * **分布式锁**:任何涉及跨实例的资源竞争(如库存扣减、定时任务),**必须**使用 Redis RedLock 或同等机制。 + --- diff --git a/Directory.Build.props b/Directory.Build.props index 59c4f40..e975914 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,5 +6,7 @@ latest false + + + - diff --git a/Document/11_SystemTodo.md b/Document/11_SystemTodo.md index 9d9adbe..dead828 100644 --- a/Document/11_SystemTodo.md +++ b/Document/11_SystemTodo.md @@ -56,6 +56,8 @@ ## 8. CI/CD 与发布 - [ ] CI/CD 流水线覆盖构建、发布、静态扫描、数据库迁移。 + - [x] `.github/workflows/ci-cd.yml` 已覆盖 Admin/Mini/User API 的变更检测、Docker Build/Push 与 SSH 部署。 + - [ ] 尚未集成静态代码扫描、安全扫描与数据库迁移自动化。 - [ ] Dev/Staging/Prod 多环境配置矩阵 + 基础设施 IaC 脚本。 - [ ] 版本与发布说明模板整理并在仓库中提供示例。 diff --git a/Document/12_BusinessTodo.md b/Document/12_BusinessTodo.md index bd3da81..00d98dd 100644 --- a/Document/12_BusinessTodo.md +++ b/Document/12_BusinessTodo.md @@ -4,26 +4,46 @@ --- ## 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 端据此限制下单时间。 +- [x] 管理端租户 API:注册、实名认证、套餐订阅/续费/升降配、审核流,Swagger ≥6 个端点,含审核日志。 + - 已交付:`src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs` 暴露注册、详情、实名提交、审核、订阅创建/升降配、审核日志 8 个端点;对应命令/查询位于 `src/Application/TakeoutSaaS.Application/App/Tenants`,仓储实现 `EfTenantRepository`,并写入 `TenantAuditLog` 记录。Swagger 自动收录上述接口,满足 Phase1 租户管理要求。 +- [x] 商家入驻 API:证照上传、合同管理、类目选择,驱动待审/审核/驳回/通过状态机,文件持久在 COS。 + - 已交付:`src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs` 新增证照上传/审核、合同创建与状态更新、商户审核、审核日志、类目列表等 8 个端点;应用层新增 `AddMerchantDocumentCommand`、`CreateMerchantContractCommand`、`ReviewMerchantCommand` 等 Handler;`MerchantDocument/Contract/Audit` DTO 完整返回详情,文件 URL 仍通过 `/api/admin/v1/files/upload` 上 COS。仓储实现扩展 `EfMerchantRepository` 支持文档/合同/AuditLog 持久化,`TakeoutAppDbContext` 新增 `merchant_audit_logs` 表实现状态机追踪。 +- [x] RBAC 模板:平台管理员、租户管理员、店长、店员四角色模板;API 可复制并允许租户自定义扩展。 + - 已交付:角色模板改为数据库驱动,新增 `RoleTemplate/RoleTemplatePermission` 实体与仓储接口/实现;应用层提供模板列表/详情/创建/更新/删除、按模板复制与租户批量初始化命令/查询;Admin 端 `RolesController` 暴露模板 CRUD 与复制/初始化端点(`src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs`),复制时补齐缺失权限且保留租户自定义授权;预置模板/权限种子写入 `src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.*.json`。 +- [x] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage。 + - 已交付:新增套餐仓储与命令/查询/DTO(`src/Application/TakeoutSaaS.Application/App/Tenants`),Admin 端新增 `TenantPackagesController` 提供套餐列表/详情/创建/更新/删除接口。新增配额校验命令与租户接口 `/api/admin/v1/tenants/{id}/quotas/check`,基于当前订阅套餐限额校验并占用配额,超额抛出 409 并写入 `TenantQuotaUsage`。仓储注册于 `AddAppInfrastructure`。 +- [x] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。 + - 已交付:新增账单/公告/通知实体与仓储,Admin 端提供 `/tenants/{id}/billings`(列表/详情/创建/标记支付)、`/announcements`(列表/详情/创建/更新/删除/已读)、`/notifications`(列表/已读)端点;权限码补充 `tenant-bill:*`、`tenant-announcement:*`、`tenant-notification:*`,种子模板更新;配额/订阅告警可通过通知表承载。 +- [x] 门店管理:Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。 + - 已交付:营业时间/配送区/节假日命令、查询、验证与处理器齐全,Admin API 子路由完成 CRUD,门店能力开关(预约/排队)对外暴露,仓储读写删除均带租户过滤。 +- [x] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIP(POST /api/admin/stores/{id}/tables 可下载)。 + - 已交付:桌台区域/桌码 DTO、命令、查询、验证与处理器完善,支持批量生成、区域绑定/更新;Admin API 增加区域/桌码 CRUD 与二维码 ZIP 导出(QRCoder 生成 SVG 打包),仓储补齐查找、更新、删除。 +- [x] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift,可查询未来 7 日排班。 + - 已交付:门店员工 DTO/命令/查询/验证/处理器完成,支持创建/更新/删除/查询;排班 CRUD(默认未来 7 天)含归属与时间冲突校验;Admin API 增加员工与排班控制器及权限种子,仓储含排班查询/更新/删除。 +- [x] 桌码扫码入口:Mini 端解析二维码,GET /api/mini/tables/{code}/context 返回门店、桌台、公告。 + - 已交付:桌码上下文查询 DTO/验证/处理器完成,可按桌码返回门店名称/公告/标签与桌台信息;MiniApi 新增 `TablesController` `/context` 端点,仓储支持按桌码查询。 +- [x] 菜品建模:分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程;Mini 端可拉取完整 JSON。 + - 已交付:Admin 侧补齐 SKU/规格/加料/媒资/定价替换命令、验证与端点,并新增上/下架接口与全量详情;权限种子补充 `product:publish` 与子资源读写。Mini 侧新增门店菜单接口,按门店返回分类 + 商品全量 JSON(含 SKU/规格/加料/媒资/定价),支持 `updatedAfter` 增量。 +- [x] 库存体系:SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。 + - 已交付:库存模型补充预售/限购/并发字段与批次策略(FIFO/FEFO),新增锁定记录与幂等、过期释放;应用层提供调整/锁定/释放/扣减/批次维护命令与查询,Admin API 暴露库存与批次端点及权限种子。需后续生成迁移落库,并可按需将过期释放接入定时任务。 +- [x] 自提档期:门店配置自提时间窗、容量、截单时间;Mini 端据此限制下单时间。 + - 已交付:新增自提设置与档期实体/表、并发控制,Admin 端提供自提配置与档期 CRUD 权限/接口;Mini 端提供按日期查询可用档期,包含截单与容量校验。下单限制待后续与订单流程联调。 - [ ] 购物车服务:ShoppingCart/CartItem/CartItemAddon API 支持并发锁、限购、券/积分预校验,保证并发无脏数据。 + - 当前:领域层与表结构已有 `ShoppingCart/CartItem/CartItemAddon`,但缺少 CQRS 命令/查询、并发锁/限购/券积分预校验以及任何 Admin/Mini 端接口。 - [ ] 订单与支付:堂食/自提/配送下单、微信/支付宝支付、优惠券/积分抵扣、订单状态机与通知链路齐全。 + - 当前:Admin 端 `OrdersController`/`PaymentsController` 仅提供基础 CRUD,未覆盖堂食/自提/配送业务流、微信/支付宝支付、优惠券/积分抵扣、订单状态机、通知链路及与库存/配送的集成,Mini 端也无下单/支付接口。 - [ ] 桌台账单:合单/拆单、结账、电子小票、桌台释放,完成结账后恢复 Idle 并生成票据 URL。 + - 当前:无桌台账单/合单/拆单/结账或电子小票逻辑,桌台仅有基础实体定义。 - [ ] 自配送骨架:骑手管理、取送件信息录入、费用补贴记录,Admin 端可派单并更新 DeliveryOrder。 + - 当前:`DeliveryOrder` CRUD 支持录入 `CourierName/Phone`,但缺少骑手管理、派单流程、取送件详情与补贴记录等自配送骨架。 - [ ] 第三方配送抽象:统一下单/取消/加价/查询接口,支持达达、美团、闪送等,含回调验签与异常补偿骨架。 + - 当前:尚未提供第三方配送抽象、回调验签或补偿逻辑,配送模块仅有基础 CRUD。 - [ ] 预购自提核销:提货码生成、手机号/二维码核销、自提柜/前台流程,超时自动取消或退款,记录操作者与时间。 + - 当前:存在 `Reservation` 实体及订单字段 `ReservationId/CheckInCode`,但未实现提货码生成、核销接口、超时取消/退款或核销人记录,未与订单支付联动。 - [ ] 指标与日志:Prometheus 输出订单创建、支付成功率、配送回调耗时等,Grafana ≥8 个图表;关键流程日志记录 TraceId + 业务 ID。 + - 当前:Admin/Mini/User API 与网关已接入 OpenTelemetry(OTLP 与 Prometheus 导出)和 TraceId 结构化日志,但缺少订单/支付/配送等业务指标定义、Prometheus 爬取路径说明及 Grafana 图表配置。 - [ ] 测试:Phase 1 核心 API 具备 ≥30 条自动化用例(单元 + 集成),覆盖租户→商户→下单链路。 + - 当前:仓库尚无自动化测试项目/用例,Phase 1 链路未覆盖 xUnit/Moq/FluentAssertions 的单元或集成测试。 --- ## Phase 2(下一阶段):拼单、优惠券与基础营销、会员积分/会员日、客服聊天、同城自配送调度、搜索 @@ -50,4 +70,4 @@ - [ ] 可靠性:幂等与重试策略、任务调度补偿、链路追踪、告警联动。 - [ ] 运营大盘:交易/营销/履约/用户维度的细分报表、GMV/成本/毛利分析。 - [ ] 文档与测试:完整测试矩阵、性能测试报告、上线手册、回滚方案。 -- [ ] 监控与运维:上线发布流程、灰度/回滚策略、系统稳定性指标、24x7 监控与告警。 \ No newline at end of file +- [ ] 监控与运维:上线发布流程、灰度/回滚策略、系统稳定性指标、24x7 监控与告警。 diff --git a/Document/API边界与自检清单.md b/Document/15_API边界与自检清单.md similarity index 84% rename from Document/API边界与自检清单.md rename to Document/15_API边界与自检清单.md index d1fa76d..53e284a 100644 --- a/Document/API边界与自检清单.md +++ b/Document/15_API边界与自检清单.md @@ -8,13 +8,14 @@ - **鉴权**:JWT + RBAC(`[Authorize]` + `PermissionAuthorize`),必须带租户头 `X-Tenant-Id/Code`。 - **路由前缀**:`api/admin/v{version}/...`。 - **DTO/约束**:仅管理字段,禁止返回 C 端敏感信息;long -> string;严禁实体直接返回。 -- **现有控制器**:`AuthController`、`DeliveriesController`、`DictionaryController`、`FilesController`、`MerchantsController`、`OrdersController`、`PaymentsController`、`PermissionsController`、`RolesController`、`StoresController`、`SystemParametersController`、`UserPermissionsController`、`HealthController`。 +- **现有控制器**:`AuthController`、`DeliveriesController`、`DictionaryController`、`FilesController`、`MerchantsController`、`OrdersController`、`PaymentsController`、`PermissionsController`、`RolesController`、`StoresController`、`SystemParametersController`、`TenantPackagesController`、`TenantsController`、`TenantBillingsController`、`TenantAnnouncementsController`、`TenantNotificationsController`、`UserPermissionsController`、`HealthController`。 - **自检清单**: 1. 是否需要权限/租户过滤?未加则补 `[Authorize]` + 租户解析。 2. 是否调用了应用层 CQRS,而非在 Controller 写业务? 3. DTO 是否按管理口径,未暴露用户端字段? 4. 是否使用参数化/AsNoTracking/投影,避免 N+1? 5. 路由和 Swagger 示例是否含租户/权限说明? +- **自检记录**:RolesController 新增模板列表/详情/复制/初始化端点,均已套用 `[Authorize]` + `PermissionAuthorize`、仅调用 CQRS/DTO,依赖租户头隔离。TenantPackagesController 与 TenantsController(配额校验) 均使用权限码、DTO 映射,配额校验要求携带租户头防越权。新增租户账单/公告/通知控制器,全部采用 CQRS、权限校验与租户参数,列表分页、未暴露实体。 ## 2. UserApi(C 端用户) - **面向对象**:App/H5 普通用户。 diff --git a/Document/CI_CD流水线.md b/Document/CI_CD流水线.md deleted file mode 100644 index 6be352f..0000000 --- a/Document/CI_CD流水线.md +++ /dev/null @@ -1,134 +0,0 @@ -# CI/CD 流水线(云效,dev 合并 master 触发) - -## 触发规则 -- 分支触发:仅 `master`。 -- 校验来源:流水线脚本内检查 `GIT_BRANCH == master` 且 `GIT_PREVIOUS_BRANCH == dev`,否则退出。 - -## 必填变量(云效“变量/密钥”) -- 字符变量: - - `REGISTRY=crpi-z1i5bludyfuvzo9o.cn-beijing.personal.cr.aliyuncs.com` - - `REGISTRY_USERNAME=heaize404@163.com` - - `DEPLOY_HOST=49.7.179.246` - - `DEPLOY_USER=root` -- 密钥/凭据: - - `REGISTRY_PASSWORD=MsuMshk112233` - - `DEPLOY_PASSWORD=7zE&84XI6~w57W7N` -- 默认基线:`BASE_REF=origin/master`(可不配)。 - -## Docker 端口约定 -- Admin:7801 -- Mini:7701 -- User:7901 - -## 完整流水线 YAML -```yaml -version: 1.0 -name: takeoutsaas-ci-cd -displayName: TakeoutSaaS CI/CD -triggers: - push: - branches: - include: - - master - -stages: - - stage: DetectChanges - name: DetectChanges - steps: - - step: Checkout - name: Checkout - checkout: self - - step: Detect - name: Detect - script: | - set -e - if [ "$GIT_BRANCH" != "master" ] || [ "$GIT_PREVIOUS_BRANCH" != "dev" ]; then - echo "非 dev->master,跳过流水线"; exit 0; fi - - git fetch origin master --depth=1 - BASE=${BASE_REF:-origin/master} - CHANGED=$(git diff --name-only "$(git merge-base $BASE HEAD)" HEAD) - echo "变更文件:" - echo "$CHANGED" - - deploy_all=false - services=() - hit(){ echo "$CHANGED" | grep -qE "$1"; } - - if hit '^src/(Domain|Application|Infrastructure|Core|Modules)/'; then deploy_all=true; fi - hit '^Directory.Build.props$' && deploy_all=true - - hit '^src/Api/TakeoutSaaS.AdminApi/' && services+=("admin-api") - hit '^src/Api/TakeoutSaaS.MiniApi/' && services+=("mini-api") - hit '^src/Api/TakeoutSaaS.UserApi/' && services+=("user-api") - - if $deploy_all || [ ${#services[@]} -eq 0 ]; then - services=("admin-api" "mini-api" "user-api") - fi - - echo "SERVICES=${services[*]}" >> "$ACROSS_STAGES_ENV_FILE" - - - stage: BuildPush - name: BuildPush - steps: - - step: DockerBuildPush - name: DockerBuildPush - script: | - set -e - IFS=' ' read -ra svcs <<< "$SERVICES" - REGISTRY=${REGISTRY:?需要配置 REGISTRY} - TAG=${TAG:-$(date +%Y%m%d%H%M%S)} - - echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin - - for svc in "${svcs[@]}"; do - case "$svc" in - admin-api) dockerfile="src/Api/TakeoutSaaS.AdminApi/Dockerfile"; image="$REGISTRY/admin-api:$TAG" ;; - mini-api) dockerfile="src/Api/TakeoutSaaS.MiniApi/Dockerfile"; image="$REGISTRY/mini-api:$TAG" ;; - user-api) dockerfile="src/Api/TakeoutSaaS.UserApi/Dockerfile"; image="$REGISTRY/user-api:$TAG" ;; - esac - echo "构建并推送 $image" - docker build -f "$dockerfile" -t "$image" . - docker push "$image" - done - echo "IMAGE_TAG=$TAG" >> "$ACROSS_STAGES_ENV_FILE" - - - stage: Deploy - name: Deploy - steps: - - step: DockerDeploy - name: DockerDeploy - script: | - set -e - command -v sshpass >/dev/null 2>&1 || (sudo apt-get update && sudo apt-get install -y sshpass) - - IFS=' ' read -ra svcs <<< "$SERVICES" - TAG="$IMAGE_TAG" - REGISTRY=${REGISTRY:?} - DEPLOY_HOST=${DEPLOY_HOST:?} - DEPLOY_USER=${DEPLOY_USER:-root} - DEPLOY_PASSWORD=${DEPLOY_PASSWORD:?} - - for svc in "${svcs[@]}"; do - case "$svc" in - admin-api) image="$REGISTRY/admin-api:$TAG"; port=7801 ;; - mini-api) image="$REGISTRY/mini-api:$TAG"; port=7701 ;; - user-api) image="$REGISTRY/user-api:$TAG"; port=7901 ;; - esac - - echo "部署 $svc -> $image" - sshpass -p "$DEPLOY_PASSWORD" ssh -o StrictHostKeyChecking=no "$DEPLOY_USER@$DEPLOY_HOST" "set -e; docker pull $image; docker stop $svc 2>/dev/null || true; docker rm $svc 2>/dev/null || true; docker run -d --name $svc --restart=always -p $port:$port $image" - done -``` - -## 注意事项 -- 以上 YAML 如仍报 YAML 校验错误,可将 `triggers` 改为: - ```yaml - on: - push: - branches: - - master - ``` - 其余保持不变。 -- 如果云效的分支变量名与 `GIT_BRANCH` / `GIT_PREVIOUS_BRANCH` 不同,请在 Detect 步骤替换为实际变量名。 -- 所有密码、密钥务必放在“密钥/凭据”类型变量中,不要写入代码库。 diff --git a/TakeoutSaaS.sln b/TakeoutSaaS.sln index d924b98..c7a3bd6 100644 --- a/TakeoutSaaS.sln +++ b/TakeoutSaaS.sln @@ -45,6 +45,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Shared.Kernel", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Storage", "src\Modules\TakeoutSaaS.Module.Storage\TakeoutSaaS.Module.Storage.csproj", "{05058F44-6FB7-43AF-8648-8BF538E283EF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Delivery", "src\Modules\TakeoutSaaS.Module.Delivery\TakeoutSaaS.Module.Delivery.csproj", "{5C12177E-6C25-4F78-BFD4-AA073CFC0650}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Dictionary", "src\Modules\TakeoutSaaS.Module.Dictionary\TakeoutSaaS.Module.Dictionary.csproj", "{5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Messaging", "src\Modules\TakeoutSaaS.Module.Messaging\TakeoutSaaS.Module.Messaging.csproj", "{FE49A9E7-1228-45BA-9B71-337AA353FE98}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Scheduler", "src\Modules\TakeoutSaaS.Module.Scheduler\TakeoutSaaS.Module.Scheduler.csproj", "{9C2F510E-4054-482D-AFD3-D2E374D60304}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TakeoutSaaS.Module.Sms", "src\Modules\TakeoutSaaS.Module.Sms\TakeoutSaaS.Module.Sms.csproj", "{38011EC3-7EC3-40E4-B9B2-E631966B350B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -211,6 +221,66 @@ Global {05058F44-6FB7-43AF-8648-8BF538E283EF}.Release|x64.Build.0 = Release|Any CPU {05058F44-6FB7-43AF-8648-8BF538E283EF}.Release|x86.ActiveCfg = Release|Any CPU {05058F44-6FB7-43AF-8648-8BF538E283EF}.Release|x86.Build.0 = Release|Any CPU + {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Debug|x64.ActiveCfg = Debug|Any CPU + {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Debug|x64.Build.0 = Debug|Any CPU + {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Debug|x86.ActiveCfg = Debug|Any CPU + {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Debug|x86.Build.0 = Debug|Any CPU + {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Release|Any CPU.Build.0 = Release|Any CPU + {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Release|x64.ActiveCfg = Release|Any CPU + {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Release|x64.Build.0 = Release|Any CPU + {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Release|x86.ActiveCfg = Release|Any CPU + {5C12177E-6C25-4F78-BFD4-AA073CFC0650}.Release|x86.Build.0 = Release|Any CPU + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Debug|x64.ActiveCfg = Debug|Any CPU + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Debug|x64.Build.0 = Debug|Any CPU + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Debug|x86.ActiveCfg = Debug|Any CPU + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Debug|x86.Build.0 = Debug|Any CPU + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Release|Any CPU.Build.0 = Release|Any CPU + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Release|x64.ActiveCfg = Release|Any CPU + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Release|x64.Build.0 = Release|Any CPU + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Release|x86.ActiveCfg = Release|Any CPU + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1}.Release|x86.Build.0 = Release|Any CPU + {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Debug|x64.ActiveCfg = Debug|Any CPU + {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Debug|x64.Build.0 = Debug|Any CPU + {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Debug|x86.ActiveCfg = Debug|Any CPU + {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Debug|x86.Build.0 = Debug|Any CPU + {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Release|Any CPU.Build.0 = Release|Any CPU + {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Release|x64.ActiveCfg = Release|Any CPU + {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Release|x64.Build.0 = Release|Any CPU + {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Release|x86.ActiveCfg = Release|Any CPU + {FE49A9E7-1228-45BA-9B71-337AA353FE98}.Release|x86.Build.0 = Release|Any CPU + {9C2F510E-4054-482D-AFD3-D2E374D60304}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C2F510E-4054-482D-AFD3-D2E374D60304}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C2F510E-4054-482D-AFD3-D2E374D60304}.Debug|x64.ActiveCfg = Debug|Any CPU + {9C2F510E-4054-482D-AFD3-D2E374D60304}.Debug|x64.Build.0 = Debug|Any CPU + {9C2F510E-4054-482D-AFD3-D2E374D60304}.Debug|x86.ActiveCfg = Debug|Any CPU + {9C2F510E-4054-482D-AFD3-D2E374D60304}.Debug|x86.Build.0 = Debug|Any CPU + {9C2F510E-4054-482D-AFD3-D2E374D60304}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C2F510E-4054-482D-AFD3-D2E374D60304}.Release|Any CPU.Build.0 = Release|Any CPU + {9C2F510E-4054-482D-AFD3-D2E374D60304}.Release|x64.ActiveCfg = Release|Any CPU + {9C2F510E-4054-482D-AFD3-D2E374D60304}.Release|x64.Build.0 = Release|Any CPU + {9C2F510E-4054-482D-AFD3-D2E374D60304}.Release|x86.ActiveCfg = Release|Any CPU + {9C2F510E-4054-482D-AFD3-D2E374D60304}.Release|x86.Build.0 = Release|Any CPU + {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Debug|x64.ActiveCfg = Debug|Any CPU + {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Debug|x64.Build.0 = Debug|Any CPU + {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Debug|x86.ActiveCfg = Debug|Any CPU + {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Debug|x86.Build.0 = Debug|Any CPU + {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|Any CPU.Build.0 = Release|Any CPU + {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x64.ActiveCfg = Release|Any CPU + {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x64.Build.0 = Release|Any CPU + {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x86.ActiveCfg = Release|Any CPU + {38011EC3-7EC3-40E4-B9B2-E631966B350B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -236,5 +306,10 @@ Global {A2620200-D487-49A7-ABAF-9B84951F81DD} = {6306A8FB-679E-111F-6585-8F70E0EE6013} {BBC99B58-ECA8-42C3-9070-9AA058D778D3} = {8D626EA8-CB54-BC41-363A-217881BEBA6E} {05058F44-6FB7-43AF-8648-8BF538E283EF} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} + {5C12177E-6C25-4F78-BFD4-AA073CFC0650} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} + {5ADA37B6-09A0-48F7-8633-C266FD5BBFD1} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} + {FE49A9E7-1228-45BA-9B71-337AA353FE98} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} + {9C2F510E-4054-482D-AFD3-D2E374D60304} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} + {38011EC3-7EC3-40E4-B9B2-E631966B350B} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} EndGlobalSection EndGlobal diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs index 72f23f4..32b4e5a 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs @@ -1,8 +1,4 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; @@ -13,24 +9,22 @@ using TakeoutSaaS.Shared.Web.Api; using TakeoutSaaS.Shared.Web.Security; namespace TakeoutSaaS.AdminApi.Controllers; - /// /// 管理后台认证接口 /// -/// -/// -/// -/// +/// 提供登录、刷新 Token 以及用户权限查询能力。 +/// 认证服务 [ApiVersion("1.0")] [Authorize] [Route("api/admin/v{version:apiVersion}/auth")] public sealed class AuthController(IAdminAuthService authService) : BaseApiController { - - /// /// 登录获取 Token /// + /// 登录请求。 + /// 取消标记。 + /// 包含访问令牌与刷新令牌的响应。 [HttpPost("login")] [AllowAnonymous] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -43,6 +37,9 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr /// /// 刷新 Token /// + /// 刷新令牌请求。 + /// 取消标记。 + /// 新的访问令牌与刷新令牌。 [HttpPost("refresh")] [AllowAnonymous] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -78,18 +75,22 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr /// } /// /// + /// 取消标记。 + /// 当前用户档案信息。 [HttpGet("profile")] [PermissionAuthorize("identity:profile:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] public async Task> GetProfile(CancellationToken cancellationToken) { + // 1. 从 JWT 中获取当前用户标识 var userId = User.GetUserId(); if (userId == 0) { return ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"); } + // 2. 读取用户档案并返回 var profile = await authService.GetProfileAsync(userId, cancellationToken); return ApiResponse.Ok(profile); } @@ -119,6 +120,9 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr /// } /// /// + /// 目标用户 ID。 + /// 取消标记。 + /// 用户权限概览,未找到则返回 404。 [HttpGet("permissions/{userId:long}")] [PermissionAuthorize("identity:permission:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs index fe5d2cf..6f0f953 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DeliveriesController.cs @@ -1,6 +1,5 @@ using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.Deliveries.Commands; using TakeoutSaaS.Application.App.Deliveries.Dto; @@ -16,31 +15,40 @@ 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) { + // 1. 创建配送单 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } /// /// 查询配送单列表。 /// + /// 订单 ID。 + /// 配送状态。 + /// 页码。 + /// 每页大小。 + /// 排序字段。 + /// 是否倒序。 + /// 取消标记。 + /// 配送单分页列表。 [HttpGet] [PermissionAuthorize("delivery:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -53,6 +61,7 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组装查询参数 var result = await mediator.Send(new SearchDeliveryOrdersQuery { OrderId = orderId, @@ -63,19 +72,26 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController SortDescending = sortDesc }, cancellationToken); + // 2. 返回分页结果 return ApiResponse>.Ok(result); } /// /// 获取配送单详情。 /// + /// 配送单 ID。 + /// 取消标记。 + /// 配送单详情或未找到。 [HttpGet("{deliveryOrderId:long}")] [PermissionAuthorize("delivery:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Detail(long deliveryOrderId, CancellationToken cancellationToken) { + // 1. 查询配送单详情 var result = await mediator.Send(new GetDeliveryOrderByIdQuery { DeliveryOrderId = deliveryOrderId }, cancellationToken); + + // 2. 返回详情或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "配送单不存在") : ApiResponse.Ok(result); @@ -84,17 +100,26 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController /// /// 更新配送单。 /// + /// 配送单 ID。 + /// 更新命令。 + /// 取消标记。 + /// 更新后的配送单或未找到。 [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) { + // 1. 确保命令携带配送单标识 if (command.DeliveryOrderId == 0) { command = command with { DeliveryOrderId = deliveryOrderId }; } + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "配送单不存在") : ApiResponse.Ok(result); @@ -103,13 +128,19 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController /// /// 删除配送单。 /// + /// 配送单 ID。 + /// 取消标记。 + /// 删除结果,未找到则返回错误。 [HttpDelete("{deliveryOrderId:long}")] [PermissionAuthorize("delivery:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long deliveryOrderId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeleteDeliveryOrderCommand { DeliveryOrderId = deliveryOrderId }, cancellationToken); + + // 2. 返回结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "配送单不存在"); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs index d98e420..f2cc347 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryController.cs @@ -12,74 +12,101 @@ 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) { + // 1. 查询字典分组 var groups = await dictionaryAppService.SearchGroupsAsync(query, cancellationToken); + + // 2. 返回分组列表 return ApiResponse>.Ok(groups); } /// /// 创建字典分组。 /// + /// 创建分组请求。 + /// 取消标记。 + /// 创建后的分组。 [HttpPost] [PermissionAuthorize("dictionary:group:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> CreateGroup([FromBody] CreateDictionaryGroupRequest request, CancellationToken cancellationToken) { + // 1. 创建字典分组 var group = await dictionaryAppService.CreateGroupAsync(request, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(group); } /// /// 更新字典分组。 /// + /// 分组 ID。 + /// 更新请求。 + /// 取消标记。 + /// 更新后的分组。 [HttpPut("{groupId:long}")] [PermissionAuthorize("dictionary:group:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> UpdateGroup(long groupId, [FromBody] UpdateDictionaryGroupRequest request, CancellationToken cancellationToken) { + // 1. 更新字典分组 var group = await dictionaryAppService.UpdateGroupAsync(groupId, request, cancellationToken); + + // 2. 返回更新结果 return ApiResponse.Ok(group); } /// /// 删除字典分组。 /// + /// 分组 ID。 + /// 取消标记。 + /// 操作结果。 [HttpDelete("{groupId:long}")] [PermissionAuthorize("dictionary:group:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> DeleteGroup(long groupId, CancellationToken cancellationToken) { + // 1. 删除字典分组 await dictionaryAppService.DeleteGroupAsync(groupId, cancellationToken); + + // 2. 返回成功响应 return ApiResponse.Success(); } /// /// 创建字典项。 /// + /// 分组 ID。 + /// 创建请求。 + /// 取消标记。 + /// 创建的字典项。 [HttpPost("{groupId:long}/items")] [PermissionAuthorize("dictionary:item:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> CreateItem(long groupId, [FromBody] CreateDictionaryItemRequest request, CancellationToken cancellationToken) { + // 1. 绑定分组标识 request.GroupId = groupId; + + // 2. 创建字典项 var item = await dictionaryAppService.CreateItemAsync(request, cancellationToken); return ApiResponse.Ok(item); } @@ -87,35 +114,54 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ /// /// 更新字典项。 /// + /// 字典项 ID。 + /// 更新请求。 + /// 取消标记。 + /// 更新后的字典项。 [HttpPut("items/{itemId:long}")] [PermissionAuthorize("dictionary:item:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> UpdateItem(long itemId, [FromBody] UpdateDictionaryItemRequest request, CancellationToken cancellationToken) { + // 1. 更新字典项 var item = await dictionaryAppService.UpdateItemAsync(itemId, request, cancellationToken); + + // 2. 返回更新结果 return ApiResponse.Ok(item); } /// /// 删除字典项。 /// + /// 字典项 ID。 + /// 取消标记。 + /// 操作结果。 [HttpDelete("items/{itemId:long}")] [PermissionAuthorize("dictionary:item:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> DeleteItem(long itemId, CancellationToken cancellationToken) { + // 1. 删除字典项 await dictionaryAppService.DeleteItemAsync(itemId, cancellationToken); + + // 2. 返回成功响应 return ApiResponse.Success(); } /// /// 批量获取字典项(命中缓存)。 /// + /// 批量查询请求。 + /// 取消标记。 + /// 分组编码到字典项列表的映射。 [HttpPost("batch")] [ProducesResponseType(typeof(ApiResponse>>), StatusCodes.Status200OK)] public async Task>>> BatchGet([FromBody] DictionaryBatchQueryRequest request, CancellationToken cancellationToken) { + // 1. 批量读取并命中缓存 var dictionaries = await dictionaryAppService.GetCachedItemsAsync(request, cancellationToken); + + // 2. 返回批量结果 return ApiResponse>>.Ok(dictionaries); } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs index f53d344..cd92566 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/FilesController.cs @@ -1,6 +1,4 @@ -using System.Linq; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Storage.Abstractions; using TakeoutSaaS.Application.Storage.Contracts; @@ -19,34 +17,38 @@ namespace TakeoutSaaS.AdminApi.Controllers; [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) { + // 1. 校验文件有效性 if (file == null || file.Length == 0) { return ApiResponse.Error(ErrorCodes.BadRequest, "文件不能为空"); } + // 2. 解析上传类型 if (!UploadFileTypeParser.TryParse(type, out var uploadType)) { return ApiResponse.Error(ErrorCodes.BadRequest, "上传类型不合法"); } + // 3. 提取请求来源 var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault(); await using var stream = file.OpenReadStream(); - var result = await _fileStorageService.UploadAsync( + // 4. 调用存储服务执行上传 + var result = await fileStorageService.UploadAsync( new UploadFileRequest(uploadType, stream, file.FileName, file.ContentType ?? string.Empty, file.Length, origin), cancellationToken); + // 5. 返回上传结果 return ApiResponse.Ok(result); } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs index 4db5f17..90dc606 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs @@ -1,6 +1,4 @@ -using System; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Shared.Abstractions.Results; using TakeoutSaaS.Shared.Web.Api; @@ -23,7 +21,10 @@ public class HealthController : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public ApiResponse Get() { + // 1. 构造健康状态 var payload = new { status = "OK", service = "AdminApi", time = DateTime.UtcNow }; + + // 2. 返回健康响应 return ApiResponse.Ok(payload); } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs new file mode 100644 index 0000000..410d5f2 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs @@ -0,0 +1,149 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Application.App.Inventory.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 库存管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/inventory")] +public sealed class InventoryController(IMediator mediator) : BaseApiController +{ + /// + /// 查询库存。 + /// + [HttpGet("{productSkuId:long}")] + [PermissionAuthorize("inventory:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Get(long storeId, long productSkuId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetInventoryItemQuery { StoreId = storeId, ProductSkuId = productSkuId }, cancellationToken); + return result is null + ? ApiResponse.Error(ErrorCodes.NotFound, "库存不存在") + : ApiResponse.Ok(result); + } + + /// + /// 调整库存(入库/盘点/报损)。 + /// + [HttpPost("adjust")] + [PermissionAuthorize("inventory:adjust")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Adjust(long storeId, [FromBody] AdjustInventoryCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 锁定库存(下单占用)。 + /// + [HttpPost("lock")] + [PermissionAuthorize("inventory:lock")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Lock(long storeId, [FromBody] LockInventoryCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 释放库存(取消订单等)。 + /// + [HttpPost("release")] + [PermissionAuthorize("inventory:release")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Release(long storeId, [FromBody] ReleaseInventoryCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 扣减库存(支付或履约成功)。 + /// + [HttpPost("deduct")] + [PermissionAuthorize("inventory:deduct")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Deduct(long storeId, [FromBody] DeductInventoryCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询批次列表。 + /// + [HttpGet("{productSkuId:long}/batches")] + [PermissionAuthorize("inventory:batch:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetBatches(long storeId, long productSkuId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetInventoryBatchesQuery + { + StoreId = storeId, + ProductSkuId = productSkuId + }, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 新增或更新批次。 + /// + [HttpPost("{productSkuId:long}/batches")] + [PermissionAuthorize("inventory:batch:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpsertBatch(long storeId, long productSkuId, [FromBody] UpsertInventoryBatchCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.ProductSkuId == 0) + { + command = command with { StoreId = storeId, ProductSkuId = productSkuId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 释放过期锁定。 + /// + [HttpPost("locks/expire")] + [PermissionAuthorize("inventory:release")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ReleaseExpiredLocks(CancellationToken cancellationToken) + { + var count = await mediator.Send(new ReleaseExpiredInventoryLocksCommand(), cancellationToken); + return ApiResponse.Ok(count); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs new file mode 100644 index 0000000..497cc87 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantCategoriesController.cs @@ -0,0 +1,95 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 商户类目管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/merchant-categories")] +public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiController +{ + /// + /// 列出所有类目。 + /// + /// 取消标记。 + /// 类目列表。 + [HttpGet] + [PermissionAuthorize("merchant_category:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List(CancellationToken cancellationToken) + { + // 1. 查询所有类目 + var result = await mediator.Send(new ListMerchantCategoriesQuery(), cancellationToken); + + // 2. 返回类目列表 + return ApiResponse>.Ok(result); + } + + /// + /// 新增类目。 + /// + /// 创建命令。 + /// 取消标记。 + /// 创建的类目。 + [HttpPost] + [PermissionAuthorize("merchant_category:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody] CreateMerchantCategoryCommand command, CancellationToken cancellationToken) + { + // 1. 创建类目 + var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 + return ApiResponse.Ok(result); + } + + /// + /// 删除类目。 + /// + /// 类目 ID。 + /// 取消标记。 + /// 删除结果,未找到则返回错误。 + [HttpDelete("{categoryId:long}")] + [PermissionAuthorize("merchant_category:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long categoryId, CancellationToken cancellationToken) + { + // 1. 执行删除 + var success = await mediator.Send(new DeleteMerchantCategoryCommand(categoryId), cancellationToken); + + // 2. 返回删除结果或 404 + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "类目不存在"); + } + + /// + /// 批量调整类目排序。 + /// + /// 排序命令。 + /// 取消标记。 + /// 执行结果。 + [HttpPost("reorder")] + [PermissionAuthorize("merchant_category:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Reorder([FromBody] ReorderMerchantCategoriesCommand command, CancellationToken cancellationToken) + { + // 1. 执行排序调整 + await mediator.Send(command, cancellationToken); + + // 2. 返回成功结果 + return ApiResponse.Ok(null); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs index dd99bb6..c5b52d0 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/MerchantsController.cs @@ -1,6 +1,5 @@ using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.Merchants.Commands; using TakeoutSaaS.Application.App.Merchants.Dto; @@ -16,31 +15,39 @@ 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) { + // 1. 创建商户 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } /// /// 查询商户列表。 /// + /// 状态筛选。 + /// 页码。 + /// 每页大小。 + /// 排序字段。 + /// 是否倒序。 + /// 取消标记。 + /// 商户分页结果。 [HttpGet] [PermissionAuthorize("merchant:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -52,6 +59,7 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组装查询参数并执行查询 var result = await mediator.Send(new SearchMerchantsQuery { Status = status, @@ -60,24 +68,34 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController SortBy = sortBy, SortDescending = sortDesc }, cancellationToken); + + // 2. 返回分页结果 return ApiResponse>.Ok(result); } /// /// 更新商户。 /// + /// 商户 ID。 + /// 更新命令。 + /// 取消标记。 + /// 更新后的商户或未找到。 [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) { + // 1. 绑定商户标识 if (command.MerchantId == 0) { command = command with { MerchantId = merchantId }; } + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "商户不存在") : ApiResponse.Ok(result); @@ -86,30 +104,225 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController /// /// 删除商户。 /// + /// 商户 ID。 + /// 取消标记。 + /// 删除结果。 [HttpDelete("{merchantId:long}")] [PermissionAuthorize("merchant:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long merchantId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeleteMerchantCommand { MerchantId = merchantId }, cancellationToken); + + // 2. 返回删除结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "商户不存在"); } /// - /// 获取商户详情。 + /// 获取商户概览。 /// + /// 商户 ID。 + /// 取消标记。 + /// 商户概览或未找到。 [HttpGet("{merchantId:long}")] [PermissionAuthorize("merchant:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Detail(long merchantId, CancellationToken cancellationToken) { + // 1. 查询商户概览 var result = await mediator.Send(new GetMerchantByIdQuery { MerchantId = merchantId }, cancellationToken); + + // 2. 返回结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "商户不存在") : ApiResponse.Ok(result); } + + /// + /// 获取商户详细资料(含证照、合同)。 + /// + /// 创建的证照信息。 + [HttpGet("{merchantId:long}/detail")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> FullDetail(long merchantId, CancellationToken cancellationToken) + { + // 1. 查询商户详细资料 + var result = await mediator.Send(new GetMerchantDetailQuery(merchantId), cancellationToken); + + // 2. 返回详情 + return ApiResponse.Ok(result); + } + + /// + /// 上传商户证照信息(先通过文件上传接口获取 COS 地址)。 + /// + /// 创建的证照信息。 + [HttpPost("{merchantId:long}/documents")] + [PermissionAuthorize("merchant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateDocument( + long merchantId, + [FromBody] AddMerchantDocumentCommand body, + CancellationToken cancellationToken) + { + // 1. 绑定商户标识 + var command = body with { MerchantId = merchantId }; + + // 2. 创建证照记录 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 商户证照列表。 + /// + /// 商户证照列表。 + [HttpGet("{merchantId:long}/documents")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Documents(long merchantId, CancellationToken cancellationToken) + { + // 1. 查询证照列表 + var result = await mediator.Send(new GetMerchantDocumentsQuery(merchantId), cancellationToken); + + // 2. 返回证照集合 + return ApiResponse>.Ok(result); + } + + /// + /// 审核指定证照。 + /// + /// 审核后的证照信息。 + [HttpPost("{merchantId:long}/documents/{documentId:long}/review")] + [PermissionAuthorize("merchant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ReviewDocument( + long merchantId, + long documentId, + [FromBody] ReviewMerchantDocumentCommand body, + CancellationToken cancellationToken) + { + // 1. 绑定商户与证照标识 + var command = body with { MerchantId = merchantId, DocumentId = documentId }; + + // 2. 执行审核 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 新增商户合同。 + /// + /// 创建的合同信息。 + [HttpPost("{merchantId:long}/contracts")] + [PermissionAuthorize("merchant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateContract( + long merchantId, + [FromBody] CreateMerchantContractCommand body, + CancellationToken cancellationToken) + { + // 1. 绑定商户标识 + var command = body with { MerchantId = merchantId }; + + // 2. 创建合同 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 合同列表。 + /// + /// 商户合同列表。 + [HttpGet("{merchantId:long}/contracts")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Contracts(long merchantId, CancellationToken cancellationToken) + { + // 1. 查询合同列表 + var result = await mediator.Send(new GetMerchantContractsQuery(merchantId), cancellationToken); + + // 2. 返回合同集合 + return ApiResponse>.Ok(result); + } + + /// + /// 更新合同状态(生效/终止等)。 + /// + /// 更新后的合同信息。 + [HttpPut("{merchantId:long}/contracts/{contractId:long}/status")] + [PermissionAuthorize("merchant:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpdateContractStatus( + long merchantId, + long contractId, + [FromBody] UpdateMerchantContractStatusCommand body, + CancellationToken cancellationToken) + { + // 1. 绑定商户与合同标识 + var command = body with { MerchantId = merchantId, ContractId = contractId }; + + // 2. 更新合同状态 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 审核商户(通过/驳回)。 + /// + /// 审核后的商户信息。 + [HttpPost("{merchantId:long}/review")] + [PermissionAuthorize("merchant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Review(long merchantId, [FromBody] ReviewMerchantCommand body, CancellationToken cancellationToken) + { + // 1. 绑定商户标识 + var command = body with { MerchantId = merchantId }; + + // 2. 执行审核 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 审核日志。 + /// + /// 商户审核日志分页结果。 + [HttpGet("{merchantId:long}/audits")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> AuditLogs( + long merchantId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + // 1. 查询审核日志 + var result = await mediator.Send(new GetMerchantAuditLogsQuery(merchantId, page, pageSize), cancellationToken); + + // 2. 返回日志分页 + return ApiResponse>.Ok(result); + } + + /// + /// 可选商户类目列表。 + /// + /// 可选的商户类目列表。 + [HttpGet("categories")] + [PermissionAuthorize("merchant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Categories(CancellationToken cancellationToken) + { + // 1. 查询可选类目 + var result = await mediator.Send(new GetMerchantCategoriesQuery(), cancellationToken); + + // 2. 返回类目列表 + return ApiResponse>.Ok(result); + } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs index 4d04390..d190e96 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/OrdersController.cs @@ -1,6 +1,5 @@ using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.Orders.Commands; using TakeoutSaaS.Application.App.Orders.Dto; @@ -17,31 +16,31 @@ 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) { + // 1. 创建订单 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } /// /// 查询订单列表。 /// + /// 订单分页列表。 [HttpGet] [PermissionAuthorize("order:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -56,6 +55,7 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组装查询参数并执行查询 var result = await mediator.Send(new SearchOrdersQuery { StoreId = storeId, @@ -68,19 +68,24 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController SortDescending = sortDesc }, cancellationToken); + // 2. 返回分页结果 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) { + // 1. 查询订单详情 var result = await mediator.Send(new GetOrderByIdQuery { OrderId = orderId }, cancellationToken); + + // 2. 返回详情或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "订单不存在") : ApiResponse.Ok(result); @@ -89,17 +94,23 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController /// /// 更新订单。 /// + /// 更新后的订单信息。 [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) { + // 1. 确保命令包含订单标识 if (command.OrderId == 0) { command = command with { OrderId = orderId }; } + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "订单不存在") : ApiResponse.Ok(result); @@ -108,13 +119,17 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController /// /// 删除订单。 /// + /// 删除结果。 [HttpDelete("{orderId:long}")] [PermissionAuthorize("order:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long orderId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeleteOrderCommand { OrderId = orderId }, cancellationToken); + + // 2. 返回结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "订单不存在"); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs index de8f322..287f893 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PaymentsController.cs @@ -1,6 +1,5 @@ using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.Payments.Commands; using TakeoutSaaS.Application.App.Payments.Dto; @@ -16,31 +15,31 @@ 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) { + // 1. 创建支付记录 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } /// /// 查询支付记录列表。 /// + /// 支付记录分页列表。 [HttpGet] [PermissionAuthorize("payment:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -53,6 +52,7 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组装查询参数并执行查询 var result = await mediator.Send(new SearchPaymentsQuery { OrderId = orderId, @@ -63,19 +63,24 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController SortDescending = sortDesc }, cancellationToken); + // 2. 返回分页结果 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) { + // 1. 查询支付记录详情 var result = await mediator.Send(new GetPaymentByIdQuery { PaymentId = paymentId }, cancellationToken); + + // 2. 返回详情或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "支付记录不存在") : ApiResponse.Ok(result); @@ -84,17 +89,23 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController /// /// 更新支付记录。 /// + /// 更新后的支付记录信息。 [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) { + // 1. 确保命令包含支付记录标识 if (command.PaymentId == 0) { command = command with { PaymentId = paymentId }; } + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "支付记录不存在") : ApiResponse.Ok(result); @@ -103,13 +114,17 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController /// /// 删除支付记录。 /// + /// 删除结果。 [HttpDelete("{paymentId:long}")] [PermissionAuthorize("payment:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long paymentId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeletePaymentCommand { PaymentId = paymentId }, cancellationToken); + + // 2. 返回结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "支付记录不存在"); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs index 26b8518..5eda408 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/PermissionsController.cs @@ -1,7 +1,6 @@ using System.ComponentModel.DataAnnotations; using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; @@ -26,6 +25,9 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle /// /// 示例:GET /api/admin/v1/permissions?keyword=order&page=1&pageSize=20 /// + /// 查询条件。 + /// 取消标记。 + /// 权限的分页结果。 [HttpGet] [PermissionAuthorize("identity:permission:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -38,6 +40,9 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle /// /// 创建权限。 /// + /// 创建命令。 + /// 取消标记。 + /// 创建的权限。 [HttpPost] [PermissionAuthorize("identity:permission:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -50,6 +55,10 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle /// /// 更新权限。 /// + /// 权限 ID。 + /// 更新命令。 + /// 取消标记。 + /// 更新后的权限,未找到时返回 404。 [HttpPut("{permissionId:long}")] [PermissionAuthorize("identity:permission:update")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] @@ -66,6 +75,9 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle /// /// 删除权限。 /// + /// 权限 ID。 + /// 取消标记。 + /// 删除结果。 [HttpDelete("{permissionId:long}")] [PermissionAuthorize("identity:permission:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs index 24e334c..0dfc911 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/ProductsController.cs @@ -1,6 +1,5 @@ using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.Products.Commands; using TakeoutSaaS.Application.App.Products.Dto; @@ -16,31 +15,31 @@ 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) { + // 1. 创建商品 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } /// /// 查询商品列表。 /// + /// 商品分页列表。 [HttpGet] [PermissionAuthorize("product:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -54,6 +53,7 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组装查询参数并执行查询 var result = await mediator.Send(new SearchProductsQuery { StoreId = storeId, @@ -65,19 +65,24 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController SortDescending = sortDesc }, cancellationToken); + // 2. 返回分页结果 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) { + // 1. 查询商品详情 var result = await mediator.Send(new GetProductByIdQuery { ProductId = productId }, cancellationToken); + + // 2. 返回详情或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") : ApiResponse.Ok(result); @@ -86,17 +91,23 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController /// /// 更新商品。 /// + /// 更新后的商品信息。 [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) { + // 1. 确保命令包含商品标识 if (command.ProductId == 0) { command = command with { ProductId = productId }; } + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") : ApiResponse.Ok(result); @@ -105,15 +116,159 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController /// /// 删除商品。 /// + /// 删除结果。 [HttpDelete("{productId:long}")] [PermissionAuthorize("product:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long productId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeleteProductCommand { ProductId = productId }, cancellationToken); + + // 2. 返回结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "商品不存在"); } + + /// + /// 获取商品全量详情。 + /// + [HttpGet("{productId:long}/detail")] + [PermissionAuthorize("product:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> FullDetail(long productId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetProductDetailQuery { ProductId = productId }, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") + : ApiResponse.Ok(result); + } + + /// + /// 上架商品。 + /// + [HttpPost("{productId:long}/publish")] + [PermissionAuthorize("product:publish")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Publish(long productId, [FromBody] PublishProductCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") + : ApiResponse.Ok(result); + } + + /// + /// 下架商品。 + /// + [HttpPost("{productId:long}/unpublish")] + [PermissionAuthorize("product:publish")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Unpublish(long productId, [FromBody] UnpublishProductCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "商品不存在") + : ApiResponse.Ok(result); + } + + /// + /// 替换商品 SKU。 + /// + [HttpPut("{productId:long}/skus")] + [PermissionAuthorize("product-sku:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ReplaceSkus(long productId, [FromBody] ReplaceProductSkusCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 替换商品规格。 + /// + [HttpPut("{productId:long}/attributes")] + [PermissionAuthorize("product-attr:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ReplaceAttributes(long productId, [FromBody] ReplaceProductAttributesCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 替换商品加料。 + /// + [HttpPut("{productId:long}/addons")] + [PermissionAuthorize("product-addon:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ReplaceAddons(long productId, [FromBody] ReplaceProductAddonsCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 替换商品媒资。 + /// + [HttpPut("{productId:long}/media")] + [PermissionAuthorize("product-media:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ReplaceMedia(long productId, [FromBody] ReplaceProductMediaCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 替换商品价格策略。 + /// + [HttpPut("{productId:long}/pricing-rules")] + [PermissionAuthorize("product-pricing:update")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ReplacePricingRules(long productId, [FromBody] ReplaceProductPricingRulesCommand command, CancellationToken cancellationToken) + { + if (command.ProductId == 0) + { + command = command with { ProductId = productId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs index a9f7148..7c97263 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/RolesController.cs @@ -1,8 +1,7 @@ -using System.ComponentModel.DataAnnotations; using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Application.Identity.Commands; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; @@ -20,6 +19,151 @@ namespace TakeoutSaaS.AdminApi.Controllers; [Route("api/admin/v{version:apiVersion}/roles")] public sealed class RolesController(IMediator mediator) : BaseApiController { + /// + /// 获取预置角色模板列表。 + /// + /// + /// 示例:GET /api/admin/v1/roles/templates + /// + /// 角色模板列表。 + [HttpGet("templates")] + [PermissionAuthorize("identity:role:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListTemplates([FromQuery] bool? isActive, CancellationToken cancellationToken) + { + // 1. 查询模板列表 + var result = await mediator.Send(new ListRoleTemplatesQuery { IsActive = isActive }, cancellationToken); + + // 2. 返回模板集合 + return ApiResponse>.Ok(result); + } + + /// + /// 获取单个角色模板详情。 + /// + /// + /// 示例:GET /api/admin/v1/roles/templates/tenant-admin + /// + /// 角色模板详情。 + [HttpGet("templates/{templateCode}")] + [PermissionAuthorize("identity:role:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> GetTemplate(string templateCode, CancellationToken cancellationToken) + { + // 1. 查询指定模板 + var result = await mediator.Send(new GetRoleTemplateQuery { TemplateCode = templateCode }, cancellationToken); + + // 2. 返回模板或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色模板不存在") + : ApiResponse.Ok(result); + } + + /// + /// 创建角色模板。 + /// + /// 创建的角色模板信息。 + [HttpPost("templates")] + [PermissionAuthorize("role-template:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateTemplate([FromBody, Required] CreateRoleTemplateCommand command, CancellationToken cancellationToken) + { + // 1. 创建模板 + var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 + return ApiResponse.Ok(result); + } + + /// + /// 更新角色模板。 + /// + /// 更新后的角色模板信息。 + [HttpPut("templates/{templateCode}")] + [PermissionAuthorize("role-template:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateTemplate( + string templateCode, + [FromBody, Required] UpdateRoleTemplateCommand command, + CancellationToken cancellationToken) + { + // 1. 绑定模板编码 + command = command with { TemplateCode = templateCode }; + + // 2. 执行更新 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色模板不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除角色模板。 + /// + /// 删除结果。 + [HttpDelete("templates/{templateCode}")] + [PermissionAuthorize("role-template:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DeleteTemplate(string templateCode, CancellationToken cancellationToken) + { + // 1. 删除模板 + var result = await mediator.Send(new DeleteRoleTemplateCommand { TemplateCode = templateCode }, cancellationToken); + + // 2. 返回执行结果 + return ApiResponse.Ok(result); + } + + /// + /// 按模板复制角色并绑定权限。 + /// + /// + /// 示例:POST /api/admin/v1/roles/templates/store-manager/copy + /// Body: { "roleName": "新区店长" } + /// + /// 创建的角色信息。 + [HttpPost("templates/{templateCode}/copy")] + [PermissionAuthorize("identity:role:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CopyFromTemplate( + string templateCode, + [FromBody, Required] CopyRoleTemplateCommand command, + CancellationToken cancellationToken) + { + // 1. 绑定模板编码 + command = command with { TemplateCode = templateCode }; + + // 2. 复制模板并返回角色 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 为当前租户批量初始化预置角色模板。 + /// + /// + /// 示例:POST /api/admin/v1/roles/templates/init + /// Body: { "templateCodes": ["tenant-admin","store-manager","store-staff"] } + /// + /// 创建的角色列表。 + [HttpPost("templates/init")] + [PermissionAuthorize("identity:role:create")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> InitializeTemplates( + [FromBody] InitializeRoleTemplatesCommand? command, + CancellationToken cancellationToken) + { + // 1. 确保命令实例存在 + command ??= new InitializeRoleTemplatesCommand(); + + // 2. 执行初始化 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + /// /// 分页查询角色。 /// @@ -28,38 +172,52 @@ 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) { + // 1. 查询角色分页 var result = await mediator.Send(query, cancellationToken); + + // 2. 返回分页数据 return ApiResponse>.Ok(result); } /// /// 创建角色。 /// + /// 创建的角色信息。 [HttpPost] [PermissionAuthorize("identity:role:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody, Required] CreateRoleCommand command, CancellationToken cancellationToken) { + // 1. 创建角色 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } /// /// 更新角色。 /// + /// 更新后的角色信息。 [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) { + // 1. 绑定角色标识 command = command with { RoleId = roleId }; + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result is null ? ApiResponse.Error(StatusCodes.Status404NotFound, "角色不存在") : ApiResponse.Ok(result); @@ -68,12 +226,16 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// /// 删除角色。 /// + /// 删除结果。 [HttpDelete("{roleId:long}")] [PermissionAuthorize("identity:role:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Delete(long roleId, CancellationToken cancellationToken) { + // 1. 构建删除命令 var command = new DeleteRoleCommand { RoleId = roleId }; + + // 2. 执行删除 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } @@ -81,12 +243,16 @@ public sealed class RolesController(IMediator mediator) : BaseApiController /// /// 绑定角色权限(覆盖式)。 /// + /// 是否绑定成功。 [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) { + // 1. 绑定角色标识 command = command with { RoleId = roleId }; + + // 2. 执行覆盖式授权 var result = await mediator.Send(command, cancellationToken); return ApiResponse.Ok(result); } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StorePickupController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StorePickupController.cs new file mode 100644 index 0000000..f07addd --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StorePickupController.cs @@ -0,0 +1,114 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 门店自提管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/pickup")] +public sealed class StorePickupController(IMediator mediator) : BaseApiController +{ + /// + /// 获取自提配置。 + /// + [HttpGet("settings")] + [PermissionAuthorize("pickup-setting:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> GetSetting(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetStorePickupSettingQuery { StoreId = storeId }, cancellationToken); + return result is null + ? ApiResponse.Error(ErrorCodes.NotFound, "未配置自提设置") + : ApiResponse.Ok(result); + } + + /// + /// 更新自提配置。 + /// + [HttpPut("settings")] + [PermissionAuthorize("pickup-setting:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> UpsertSetting(long storeId, [FromBody] UpsertStorePickupSettingCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 查询档期列表。 + /// + [HttpGet("slots")] + [PermissionAuthorize("pickup-slot:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListSlots(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStorePickupSlotsQuery { StoreId = storeId }, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 创建档期。 + /// + [HttpPost("slots")] + [PermissionAuthorize("pickup-slot:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateSlot(long storeId, [FromBody] CreateStorePickupSlotCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新档期。 + /// + [HttpPut("slots/{slotId:long}")] + [PermissionAuthorize("pickup-slot:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateSlot(long storeId, long slotId, [FromBody] UpdateStorePickupSlotCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.SlotId == 0) + { + command = command with { StoreId = storeId, SlotId = slotId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result is null + ? ApiResponse.Error(ErrorCodes.NotFound, "档期不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除档期。 + /// + [HttpDelete("slots/{slotId:long}")] + [PermissionAuthorize("pickup-slot:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> DeleteSlot(long storeId, long slotId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStorePickupSlotCommand { StoreId = storeId, SlotId = slotId }, cancellationToken); + return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "档期不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreShiftsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreShiftsController.cs new file mode 100644 index 0000000..4b6fa2d --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreShiftsController.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 门店排班管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/shifts")] +public sealed class StoreShiftsController(IMediator mediator) : BaseApiController +{ + /// + /// 查询排班(默认未来 7 天)。 + /// + [HttpGet] + [PermissionAuthorize("store-shift:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List( + long storeId, + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] long? staffId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreEmployeeShiftsQuery + { + StoreId = storeId, + From = from, + To = to, + StaffId = staffId + }, cancellationToken); + + return ApiResponse>.Ok(result); + } + + /// + /// 创建排班。 + /// + [HttpPost] + [PermissionAuthorize("store-shift:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long storeId, [FromBody] CreateStoreEmployeeShiftCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新排班。 + /// + [HttpPut("{shiftId:long}")] + [PermissionAuthorize("store-shift:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long storeId, long shiftId, [FromBody] UpdateStoreEmployeeShiftCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.ShiftId == 0) + { + command = command with { StoreId = storeId, ShiftId = shiftId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "排班不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除排班。 + /// + [HttpDelete("{shiftId:long}")] + [PermissionAuthorize("store-shift:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long storeId, long shiftId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreEmployeeShiftCommand { StoreId = storeId, ShiftId = shiftId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "排班不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreStaffsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreStaffsController.cs new file mode 100644 index 0000000..f578d05 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreStaffsController.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 门店员工管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/staffs")] +public sealed class StoreStaffsController(IMediator mediator) : BaseApiController +{ + /// + /// 查询门店员工列表。 + /// + [HttpGet] + [PermissionAuthorize("store-staff:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List( + long storeId, + [FromQuery] StaffRoleType? role, + [FromQuery] StaffStatus? status, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreStaffQuery + { + StoreId = storeId, + RoleType = role, + Status = status + }, cancellationToken); + + return ApiResponse>.Ok(result); + } + + /// + /// 创建门店员工。 + /// + [HttpPost] + [PermissionAuthorize("store-staff:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long storeId, [FromBody] CreateStoreStaffCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新门店员工。 + /// + [HttpPut("{staffId:long}")] + [PermissionAuthorize("store-staff:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long storeId, long staffId, [FromBody] UpdateStoreStaffCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.StaffId == 0) + { + command = command with { StoreId = storeId, StaffId = staffId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "员工不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除门店员工。 + /// + [HttpDelete("{staffId:long}")] + [PermissionAuthorize("store-staff:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long storeId, long staffId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreStaffCommand { StoreId = storeId, StaffId = staffId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "员工不存在"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTableAreasController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTableAreasController.cs new file mode 100644 index 0000000..26275da --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTableAreasController.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 门店桌台区域管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/table-areas")] +public sealed class StoreTableAreasController(IMediator mediator) : BaseApiController +{ + /// + /// 查询区域列表。 + /// + [HttpGet] + [PermissionAuthorize("store-table-area:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreTableAreasQuery { StoreId = storeId }, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 创建区域。 + /// + [HttpPost] + [PermissionAuthorize("store-table-area:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long storeId, [FromBody] CreateStoreTableAreaCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新区域。 + /// + [HttpPut("{areaId:long}")] + [PermissionAuthorize("store-table-area:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long storeId, long areaId, [FromBody] UpdateStoreTableAreaCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.AreaId == 0) + { + command = command with { StoreId = storeId, AreaId = areaId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "桌台区域不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除区域。 + /// + [HttpDelete("{areaId:long}")] + [PermissionAuthorize("store-table-area:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long storeId, long areaId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreTableAreaCommand { StoreId = storeId, AreaId = areaId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "桌台区域不存在或不可删除"); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTablesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTablesController.cs new file mode 100644 index 0000000..01af828 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoreTablesController.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 门店桌码管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/tables")] +public sealed class StoreTablesController(IMediator mediator) : BaseApiController +{ + /// + /// 查询桌码列表。 + /// + [HttpGet] + [PermissionAuthorize("store-table:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List( + long storeId, + [FromQuery] long? areaId, + [FromQuery] StoreTableStatus? status, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreTablesQuery + { + StoreId = storeId, + AreaId = areaId, + Status = status + }, cancellationToken); + + return ApiResponse>.Ok(result); + } + + /// + /// 批量生成桌码。 + /// + [HttpPost] + [PermissionAuthorize("store-table:create")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Generate(long storeId, [FromBody] GenerateStoreTablesCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 更新桌码。 + /// + [HttpPut("{tableId:long}")] + [PermissionAuthorize("store-table:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long storeId, long tableId, [FromBody] UpdateStoreTableCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.TableId == 0) + { + command = command with { StoreId = storeId, TableId = tableId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "桌码不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除桌码。 + /// + [HttpDelete("{tableId:long}")] + [PermissionAuthorize("store-table:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long storeId, long tableId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreTableCommand { StoreId = storeId, TableId = tableId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "桌码不存在"); + } + + /// + /// 导出桌码二维码 ZIP。 + /// + [HttpPost("export")] + [PermissionAuthorize("store-table:export")] + [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task Export(long storeId, [FromBody] ExportStoreTableQRCodesQuery query, CancellationToken cancellationToken) + { + if (query.StoreId == 0) + { + query = query with { StoreId = storeId }; + } + + var result = await mediator.Send(query, cancellationToken); + if (result is null) + { + return Ok(ApiResponse.Error(ErrorCodes.NotFound, "未找到可导出的桌码")); + } + + return File(result.Content, result.ContentType, result.FileName); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs index 6abdac2..7d51d20 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/StoresController.cs @@ -1,6 +1,6 @@ +using System.Collections.Generic; using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.Stores.Commands; using TakeoutSaaS.Application.App.Stores.Dto; @@ -16,31 +16,31 @@ 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) { + // 1. 创建门店 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } /// /// 查询门店列表。 /// + /// 门店分页列表。 [HttpGet] [PermissionAuthorize("store:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -53,6 +53,7 @@ public sealed class StoresController(IMediator mediator) : BaseApiController [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组装查询参数并执行查询 var result = await mediator.Send(new SearchStoresQuery { MerchantId = merchantId, @@ -63,19 +64,24 @@ public sealed class StoresController(IMediator mediator) : BaseApiController SortDescending = sortDesc }, cancellationToken); + // 2. 返回分页结果 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) { + // 1. 查询门店详情 var result = await mediator.Send(new GetStoreByIdQuery { StoreId = storeId }, cancellationToken); + + // 2. 返回详情或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "门店不存在") : ApiResponse.Ok(result); @@ -84,17 +90,23 @@ public sealed class StoresController(IMediator mediator) : BaseApiController /// /// 更新门店。 /// + /// 更新后的门店信息。 [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) { + // 1. 确保命令包含门店标识 if (command.StoreId == 0) { command = command with { StoreId = storeId }; } + + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "门店不存在") : ApiResponse.Ok(result); @@ -103,15 +115,211 @@ public sealed class StoresController(IMediator mediator) : BaseApiController /// /// 删除门店。 /// + /// 删除结果。 [HttpDelete("{storeId:long}")] [PermissionAuthorize("store:delete")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] public async Task> Delete(long storeId, CancellationToken cancellationToken) { + // 1. 执行删除 var success = await mediator.Send(new DeleteStoreCommand { StoreId = storeId }, cancellationToken); + + // 2. 返回结果或 404 return success ? ApiResponse.Ok(null) : ApiResponse.Error(ErrorCodes.NotFound, "门店不存在"); } + + /// + /// 查询门店营业时段。 + /// + [HttpGet("{storeId:long}/business-hours")] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListBusinessHours(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreBusinessHoursQuery { StoreId = storeId }, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 新增营业时段。 + /// + [HttpPost("{storeId:long}/business-hours")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateBusinessHour(long storeId, [FromBody] CreateStoreBusinessHourCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新营业时段。 + /// + [HttpPut("{storeId:long}/business-hours/{businessHourId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateBusinessHour(long storeId, long businessHourId, [FromBody] UpdateStoreBusinessHourCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.BusinessHourId == 0) + { + command = command with { StoreId = storeId, BusinessHourId = businessHourId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "营业时段不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除营业时段。 + /// + [HttpDelete("{storeId:long}/business-hours/{businessHourId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> DeleteBusinessHour(long storeId, long businessHourId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreBusinessHourCommand { StoreId = storeId, BusinessHourId = businessHourId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "营业时段不存在"); + } + + /// + /// 查询配送区域。 + /// + [HttpGet("{storeId:long}/delivery-zones")] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListDeliveryZones(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreDeliveryZonesQuery { StoreId = storeId }, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 新增配送区域。 + /// + [HttpPost("{storeId:long}/delivery-zones")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateDeliveryZone(long storeId, [FromBody] CreateStoreDeliveryZoneCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新配送区域。 + /// + [HttpPut("{storeId:long}/delivery-zones/{deliveryZoneId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateDeliveryZone(long storeId, long deliveryZoneId, [FromBody] UpdateStoreDeliveryZoneCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.DeliveryZoneId == 0) + { + command = command with { StoreId = storeId, DeliveryZoneId = deliveryZoneId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "配送区域不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除配送区域。 + /// + [HttpDelete("{storeId:long}/delivery-zones/{deliveryZoneId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> DeleteDeliveryZone(long storeId, long deliveryZoneId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreDeliveryZoneCommand { StoreId = storeId, DeliveryZoneId = deliveryZoneId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "配送区域不存在"); + } + + /// + /// 查询门店节假日。 + /// + [HttpGet("{storeId:long}/holidays")] + [PermissionAuthorize("store:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListHolidays(long storeId, CancellationToken cancellationToken) + { + var result = await mediator.Send(new ListStoreHolidaysQuery { StoreId = storeId }, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 新增节假日配置。 + /// + [HttpPost("{storeId:long}/holidays")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateHoliday(long storeId, [FromBody] CreateStoreHolidayCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0) + { + command = command with { StoreId = storeId }; + } + + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新节假日配置。 + /// + [HttpPut("{storeId:long}/holidays/{holidayId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> UpdateHoliday(long storeId, long holidayId, [FromBody] UpdateStoreHolidayCommand command, CancellationToken cancellationToken) + { + if (command.StoreId == 0 || command.HolidayId == 0) + { + command = command with { StoreId = storeId, HolidayId = holidayId }; + } + + var result = await mediator.Send(command, cancellationToken); + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "节假日配置不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除节假日配置。 + /// + [HttpDelete("{storeId:long}/holidays/{holidayId:long}")] + [PermissionAuthorize("store:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> DeleteHoliday(long storeId, long holidayId, CancellationToken cancellationToken) + { + var success = await mediator.Send(new DeleteStoreHolidayCommand { StoreId = storeId, HolidayId = holidayId }, cancellationToken); + return success + ? ApiResponse.Ok(null) + : ApiResponse.Error(ErrorCodes.NotFound, "节假日配置不存在"); + } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs index 045d649..7a2fcb2 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/SystemParametersController.cs @@ -1,6 +1,5 @@ using MediatR; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.App.SystemParameters.Commands; using TakeoutSaaS.Application.App.SystemParameters.Dto; @@ -26,18 +25,23 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont /// /// 创建系统参数。 /// + /// 创建的系统参数信息。 [HttpPost] [PermissionAuthorize("system-parameter:create")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> Create([FromBody] CreateSystemParameterCommand command, CancellationToken cancellationToken) { + // 1. 创建系统参数 var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 return ApiResponse.Ok(result); } /// /// 查询系统参数列表。 /// + /// 分页的系统参数列表。 [HttpGet] [PermissionAuthorize("system-parameter:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -50,6 +54,7 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont [FromQuery] bool sortDesc = true, CancellationToken cancellationToken = default) { + // 1. 组合查询参数 var result = await mediator.Send(new SearchSystemParametersQuery { Keyword = keyword, @@ -60,19 +65,24 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont SortDescending = sortDesc }, cancellationToken); + // 2. 返回分页结果 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) { + // 1. 查询参数详情 var result = await mediator.Send(new GetSystemParameterByIdQuery(parameterId), cancellationToken); + + // 2. 返回详情或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "系统参数不存在") : ApiResponse.Ok(result); @@ -81,18 +91,23 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont /// /// 更新系统参数。 /// + /// 更新后的系统参数信息。 [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) { + // 1. 确保命令包含参数标识 if (command.ParameterId == 0) { command = command with { ParameterId = parameterId }; } + // 2. 执行更新 var result = await mediator.Send(command, cancellationToken); + + // 3. 返回结果或 404 return result == null ? ApiResponse.Error(ErrorCodes.NotFound, "系统参数不存在") : ApiResponse.Ok(result); @@ -101,13 +116,17 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont /// /// 删除系统参数。 /// + /// 删除结果。 [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) { + // 1. 执行删除 var success = await mediator.Send(new DeleteSystemParameterCommand { ParameterId = parameterId }, cancellationToken); + + // 2. 返回成功或 404 return success ? ApiResponse.Success() : ApiResponse.Error(ErrorCodes.NotFound, "系统参数不存在"); diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs new file mode 100644 index 0000000..b99b30f --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantAnnouncementsController.cs @@ -0,0 +1,133 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户公告管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/announcements")] +public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询公告。 + /// + /// 租户公告分页结果。 + [HttpGet] + [PermissionAuthorize("tenant-announcement:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search(long tenantId, [FromQuery] SearchTenantAnnouncementsQuery query, CancellationToken cancellationToken) + { + // 1. 绑定租户标识 + query = query with { TenantId = tenantId }; + + // 2. 查询公告列表 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回分页结果 + return ApiResponse>.Ok(result); + } + + /// + /// 公告详情。 + /// + /// 租户公告详情。 + [HttpGet("{announcementId:long}")] + [PermissionAuthorize("tenant-announcement:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long tenantId, long announcementId, CancellationToken cancellationToken) + { + // 1. 查询指定公告 + var result = await mediator.Send(new GetTenantAnnouncementQuery { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); + + // 2. 返回详情或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") + : ApiResponse.Ok(result); + } + + /// + /// 创建公告。 + /// + /// 创建的公告信息。 + [HttpPost] + [PermissionAuthorize("tenant-announcement:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long tenantId, [FromBody, Required] CreateTenantAnnouncementCommand command, CancellationToken cancellationToken) + { + // 1. 绑定租户标识 + command = command with { TenantId = tenantId }; + + // 2. 创建公告并返回 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 更新公告。 + /// + /// 更新后的公告信息。 + [HttpPut("{announcementId:long}")] + [PermissionAuthorize("tenant-announcement:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long tenantId, long announcementId, [FromBody, Required] UpdateTenantAnnouncementCommand command, CancellationToken cancellationToken) + { + // 1. 绑定租户与公告标识 + command = command with { TenantId = tenantId, AnnouncementId = announcementId }; + + // 2. 执行更新 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除公告。 + /// + /// 删除结果。 + [HttpDelete("{announcementId:long}")] + [PermissionAuthorize("tenant-announcement:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Delete(long tenantId, long announcementId, CancellationToken cancellationToken) + { + // 1. 删除公告 + var result = await mediator.Send(new DeleteTenantAnnouncementCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); + + // 2. 返回执行结果 + return ApiResponse.Ok(result); + } + + /// + /// 标记公告已读。 + /// + /// 标记已读后的公告信息。 + [HttpPost("{announcementId:long}/read")] + [PermissionAuthorize("tenant-announcement:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> MarkRead(long tenantId, long announcementId, CancellationToken cancellationToken) + { + // 1. 标记公告已读 + var result = await mediator.Send(new MarkTenantAnnouncementReadCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken); + + // 2. 返回结果或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "公告不存在") + : ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs new file mode 100644 index 0000000..9de398f --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantBillingsController.cs @@ -0,0 +1,98 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户账单管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/billings")] +public sealed class TenantBillingsController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询账单。 + /// + /// 租户账单分页结果。 + [HttpGet] + [PermissionAuthorize("tenant-bill:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search(long tenantId, [FromQuery] SearchTenantBillsQuery query, CancellationToken cancellationToken) + { + // 1. 绑定租户标识 + query = query with { TenantId = tenantId }; + + // 2. 查询账单列表 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回分页结果 + return ApiResponse>.Ok(result); + } + + /// + /// 账单详情。 + /// + /// 租户账单详情。 + [HttpGet("{billingId:long}")] + [PermissionAuthorize("tenant-bill:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long tenantId, long billingId, CancellationToken cancellationToken) + { + // 1. 查询账单详情 + var result = await mediator.Send(new GetTenantBillQuery { TenantId = tenantId, BillingId = billingId }, cancellationToken); + + // 2. 返回详情或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "账单不存在") + : ApiResponse.Ok(result); + } + + /// + /// 创建账单。 + /// + /// 创建的账单信息。 + [HttpPost] + [PermissionAuthorize("tenant-bill:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create(long tenantId, [FromBody, Required] CreateTenantBillingCommand command, CancellationToken cancellationToken) + { + // 1. 绑定租户标识 + command = command with { TenantId = tenantId }; + + // 2. 创建账单 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 标记账单已支付。 + /// + /// 标记支付后的账单信息。 + [HttpPost("{billingId:long}/pay")] + [PermissionAuthorize("tenant-bill:pay")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> MarkPaid(long tenantId, long billingId, [FromBody, Required] MarkTenantBillingPaidCommand command, CancellationToken cancellationToken) + { + // 1. 绑定租户与账单标识 + command = command with { TenantId = tenantId, BillingId = billingId }; + + // 2. 标记支付状态 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回结果或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "账单不存在") + : ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs new file mode 100644 index 0000000..18c78ce --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantNotificationsController.cs @@ -0,0 +1,58 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户通知接口。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/notifications")] +public sealed class TenantNotificationsController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询通知。 + /// + /// 租户通知分页结果。 + [HttpGet] + [PermissionAuthorize("tenant-notification:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search(long tenantId, [FromQuery] SearchTenantNotificationsQuery query, CancellationToken cancellationToken) + { + // 1. 绑定租户标识 + query = query with { TenantId = tenantId }; + + // 2. 查询通知列表 + var result = await mediator.Send(query, cancellationToken); + + // 3. 返回分页结果 + return ApiResponse>.Ok(result); + } + + /// + /// 标记通知已读。 + /// + /// 标记已读后的通知信息。 + [HttpPost("{notificationId:long}/read")] + [PermissionAuthorize("tenant-notification:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> MarkRead(long tenantId, long notificationId, CancellationToken cancellationToken) + { + // 1. 标记通知为已读 + var result = await mediator.Send(new MarkTenantNotificationReadCommand { TenantId = tenantId, NotificationId = notificationId }, cancellationToken); + + // 2. 返回结果或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "通知不存在") + : ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs new file mode 100644 index 0000000..455c095 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantPackagesController.cs @@ -0,0 +1,122 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户套餐管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenant-packages")] +public sealed class TenantPackagesController(IMediator mediator) : BaseApiController +{ + /// + /// 分页查询租户套餐。 + /// + /// 查询条件。 + /// 取消标记。 + /// 租户套餐分页结果。 + [HttpGet] + [PermissionAuthorize("tenant-package:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search([FromQuery] SearchTenantPackagesQuery query, CancellationToken cancellationToken) + { + // 1. 查询租户套餐分页 + var result = await mediator.Send(query, cancellationToken); + + // 2. 返回结果 + return ApiResponse>.Ok(result); + } + + /// + /// 查看套餐详情。 + /// + /// 套餐 ID。 + /// 取消标记。 + /// 套餐详情或未找到。 + [HttpGet("{tenantPackageId:long}")] + [PermissionAuthorize("tenant-package:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long tenantPackageId, CancellationToken cancellationToken) + { + // 1. 查询套餐详情 + var result = await mediator.Send(new GetTenantPackageByIdQuery { TenantPackageId = tenantPackageId }, cancellationToken); + + // 2. 返回查询结果或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "套餐不存在") + : ApiResponse.Ok(result); + } + + /// + /// 创建套餐。 + /// + /// 创建命令。 + /// 取消标记。 + /// 创建后的套餐。 + [HttpPost] + [PermissionAuthorize("tenant-package:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody, Required] CreateTenantPackageCommand command, CancellationToken cancellationToken) + { + // 1. 执行创建 + var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 + return ApiResponse.Ok(result); + } + + /// + /// 更新套餐。 + /// + /// 套餐 ID。 + /// 更新命令。 + /// 取消标记。 + /// 更新后的套餐或未找到。 + [HttpPut("{tenantPackageId:long}")] + [PermissionAuthorize("tenant-package:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long tenantPackageId, [FromBody, Required] UpdateTenantPackageCommand command, CancellationToken cancellationToken) + { + // 1. 绑定路由 ID + command = command with { TenantPackageId = tenantPackageId }; + + // 2. 执行更新 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回更新结果或 404 + return result is null + ? ApiResponse.Error(StatusCodes.Status404NotFound, "套餐不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除套餐。 + /// + /// 套餐 ID。 + /// 取消标记。 + /// 删除结果。 + [HttpDelete("{tenantPackageId:long}")] + [PermissionAuthorize("tenant-package:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Delete(long tenantPackageId, CancellationToken cancellationToken) + { + // 1. 构建删除命令 + var command = new DeleteTenantPackageCommand { TenantPackageId = tenantPackageId }; + + // 2. 执行删除并返回 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs new file mode 100644 index 0000000..f3bc0a0 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs @@ -0,0 +1,195 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 租户管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/tenants")] +public sealed class TenantsController(IMediator mediator) : BaseApiController +{ + /// + /// 注册租户并初始化套餐。 + /// + /// 注册的租户信息。 + [HttpPost] + [PermissionAuthorize("tenant:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Register([FromBody] RegisterTenantCommand command, CancellationToken cancellationToken) + { + // 1. 注册租户并初始化套餐 + var result = await mediator.Send(command, cancellationToken); + + // 2. 返回注册结果 + return ApiResponse.Ok(result); + } + + /// + /// 分页查询租户。 + /// + /// 租户分页结果。 + [HttpGet] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> Search([FromQuery] SearchTenantsQuery query, CancellationToken cancellationToken) + { + // 1. 查询租户分页 + var result = await mediator.Send(query, cancellationToken); + + // 2. 返回分页数据 + return ApiResponse>.Ok(result); + } + + /// + /// 查看租户详情。 + /// + /// 租户详情。 + [HttpGet("{tenantId:long}")] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Detail(long tenantId, CancellationToken cancellationToken) + { + // 1. 查询租户详情 + var result = await mediator.Send(new GetTenantByIdQuery(tenantId), cancellationToken); + + // 2. 返回租户信息 + return ApiResponse.Ok(result); + } + + /// + /// 提交或更新实名认证资料。 + /// + /// 提交的实名认证信息。 + [HttpPost("{tenantId:long}/verification")] + [PermissionAuthorize("tenant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> SubmitVerification( + long tenantId, + [FromBody] SubmitTenantVerificationCommand body, + CancellationToken cancellationToken) + { + // 1. 合并路由中的租户标识 + var command = body with { TenantId = tenantId }; + + // 2. 提交或更新认证资料 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回认证结果 + return ApiResponse.Ok(result); + } + + /// + /// 审核租户。 + /// + /// 审核后的租户信息。 + [HttpPost("{tenantId:long}/review")] + [PermissionAuthorize("tenant:review")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Review(long tenantId, [FromBody] ReviewTenantCommand body, CancellationToken cancellationToken) + { + // 1. 绑定租户标识 + var command = body with { TenantId = tenantId }; + + // 2. 执行审核 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回审核结果 + return ApiResponse.Ok(result); + } + + /// + /// 创建或续费租户订阅。 + /// + /// 创建或续费的订阅信息。 + [HttpPost("{tenantId:long}/subscriptions")] + [PermissionAuthorize("tenant:subscription")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateSubscription( + long tenantId, + [FromBody] CreateTenantSubscriptionCommand body, + CancellationToken cancellationToken) + { + // 1. 绑定租户并创建或续费订阅 + var command = body with { TenantId = tenantId }; + + // 2. 返回订阅结果 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 套餐升降配。 + /// + /// 更新后的订阅信息。 + [HttpPut("{tenantId:long}/subscriptions/{subscriptionId:long}/plan")] + [PermissionAuthorize("tenant:subscription")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ChangePlan( + long tenantId, + long subscriptionId, + [FromBody] ChangeTenantSubscriptionPlanCommand body, + CancellationToken cancellationToken) + { + // 1. 绑定租户与订阅标识 + var command = body with { TenantId = tenantId, TenantSubscriptionId = subscriptionId }; + + // 2. 执行升降配 + var result = await mediator.Send(command, cancellationToken); + + // 3. 返回调整后的订阅 + return ApiResponse.Ok(result); + } + + /// + /// 查询审核日志。 + /// + /// 租户审核日志分页结果。 + [HttpGet("{tenantId:long}/audits")] + [PermissionAuthorize("tenant:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> AuditLogs( + long tenantId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + // 1. 构造审核日志查询 + var query = new GetTenantAuditLogsQuery(tenantId, page, pageSize); + + // 2. 查询并返回分页结果 + var result = await mediator.Send(query, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 配额校验并占用额度(门店/账号/短信/配送)。 + /// + /// 需在请求头携带 X-Tenant-Id 对应的租户。 + /// 配额校验结果。 + [HttpPost("{tenantId:long}/quotas/check")] + [PermissionAuthorize("tenant:quota:check")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CheckQuota( + long tenantId, + [FromBody, Required] CheckTenantQuotaCommand body, + CancellationToken cancellationToken) + { + // 1. 绑定租户标识 + var command = body with { TenantId = tenantId }; + + // 2. 校验并占用配额 + var result = await mediator.Send(command, cancellationToken); + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs index 328499a..c6a4a04 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/UserPermissionsController.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; @@ -51,6 +50,9 @@ public sealed class UserPermissionsController(IAdminAuthService authService) : B /// } /// /// + /// 搜索条件。 + /// 取消标记。 + /// 分页的用户权限概览。 [HttpGet] [PermissionAuthorize("identity:permission:read")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] @@ -58,6 +60,7 @@ public sealed class UserPermissionsController(IAdminAuthService authService) : B [FromQuery] SearchUserPermissionsQuery query, CancellationToken cancellationToken) { + // 1. 查询当前租户的用户权限概览 var result = await authService.SearchUserPermissionsAsync( query.Keyword, query.Page, @@ -66,6 +69,7 @@ public sealed class UserPermissionsController(IAdminAuthService authService) : B query.SortDescending, cancellationToken); + // 2. 返回分页结果 return ApiResponse>.Ok(result); } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Program.cs b/src/Api/TakeoutSaaS.AdminApi/Program.cs index 32b6036..3b0c4b6 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Program.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Program.cs @@ -1,10 +1,4 @@ -using System; -using System.Linq; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -26,13 +20,16 @@ using TakeoutSaaS.Module.Tenancy.Extensions; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; +// 1. 创建构建器与日志模板 var builder = WebApplication.CreateBuilder(args); const string logTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [TraceId:{TraceId}] [SpanId:{SpanId}] [Service:{Service}] {SourceContext} {Message:lj}{NewLine}{Exception}"; +// 2. 加载种子配置文件 builder.Configuration .AddJsonFile("appsettings.Seed.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.Seed.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true); +// 3. 配置 Serilog 输出 builder.Host.UseSerilog((context, _, configuration) => { configuration @@ -47,6 +44,7 @@ builder.Host.UseSerilog((context, _, configuration) => outputTemplate: logTemplate); }); +// 4. 注册通用 Web 能力与 Swagger builder.Services.AddSharedWebCore(); builder.Services.AddSharedSwagger(options => { @@ -54,6 +52,8 @@ builder.Services.AddSharedSwagger(options => options.Description = "管理后台 API 文档"; options.EnableAuthorization = true; }); + +// 5. 注册领域与基础设施模块 builder.Services.AddIdentityApplication(); builder.Services.AddIdentityInfrastructure(builder.Configuration, enableAdminSeed: true); builder.Services.AddAppInfrastructure(builder.Configuration); @@ -71,6 +71,8 @@ builder.Services.AddMessagingModule(builder.Configuration); builder.Services.AddMessagingApplication(); builder.Services.AddSchedulerModule(builder.Configuration); builder.Services.AddHealthChecks(); + +// 6. 配置 OpenTelemetry 采集 var otelSection = builder.Configuration.GetSection("Otel"); var otelEndpoint = otelSection.GetValue("Endpoint"); var useConsoleExporter = otelSection.GetValue("UseConsoleExporter") ?? builder.Environment.IsDevelopment(); @@ -86,7 +88,6 @@ builder.Services.AddOpenTelemetry() .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddEntityFrameworkCoreInstrumentation(); - if (!string.IsNullOrWhiteSpace(otelEndpoint)) { tracing.AddOtlpExporter(exporter => @@ -107,7 +108,6 @@ builder.Services.AddOpenTelemetry() .AddHttpClientInstrumentation() .AddRuntimeInstrumentation() .AddPrometheusExporter(); - if (!string.IsNullOrWhiteSpace(otelEndpoint)) { metrics.AddOtlpExporter(exporter => @@ -122,6 +122,7 @@ builder.Services.AddOpenTelemetry() } }); +// 7. 解析并配置 CORS var adminOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Admin"); builder.Services.AddCors(options => { @@ -131,8 +132,8 @@ builder.Services.AddCors(options => }); }); +// 8. 构建应用并配置中间件管道 var app = builder.Build(); - app.UseCors("AdminApiCors"); app.UseTenantResolution(); app.UseSharedWebCore(); @@ -140,12 +141,12 @@ app.UseAuthentication(); app.UseAuthorization(); app.UseSharedSwagger(); app.UseSchedulerDashboard(builder.Configuration); - app.MapHealthChecks("/healthz"); app.MapPrometheusScrapingEndpoint(); app.MapControllers(); app.Run(); +// 9. 解析配置中的 CORS 域名 static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey) { var origins = configuration.GetSection(sectionKey).Get(); @@ -155,6 +156,7 @@ static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionK .ToArray() ?? []; } +// 10. 构建 CORS 策略 static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins) { if (origins.Length == 0) diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json index 1bc5044..3955d16 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json @@ -173,4 +173,4 @@ "Sampling": "ParentBasedAlwaysOn", "UseConsoleExporter": true } -} \ No newline at end of file +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json index 1bc5044..3955d16 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json @@ -173,4 +173,4 @@ "Sampling": "ParentBasedAlwaysOn", "UseConsoleExporter": true } -} \ No newline at end of file +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index 3079e81..5e78e4f 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -39,6 +39,295 @@ }, "Identity": { "AdminSeed": { + "RoleTemplates": [ + { + "TemplateCode": "platform-admin", + "Name": "平台管理员", + "Description": "平台全量权限", + "IsActive": true, + "Permissions": [ + "identity:profile:read", + "identity:role:read", + "identity:role:create", + "identity:role:update", + "identity:role:delete", + "identity:role:bind-permission", + "identity:permission:read", + "identity:permission:create", + "identity:permission:update", + "identity:permission:delete", + "role-template:read", + "role-template:create", + "role-template:update", + "role-template:delete", + "tenant-bill:read", + "tenant-bill:create", + "tenant-bill:pay", + "tenant-announcement:read", + "tenant-announcement:create", + "tenant-announcement:update", + "tenant-announcement:delete", + "tenant-notification:read", + "tenant-notification:update", + "tenant:create", + "tenant:read", + "tenant:review", + "tenant:subscription", + "tenant:quota:check", + "tenant-package:read", + "tenant-package:create", + "tenant-package:update", + "tenant-package:delete", + "merchant:create", + "merchant:read", + "merchant:update", + "merchant:delete", + "merchant:review", + "merchant_category:read", + "merchant_category:create", + "merchant_category:update", + "merchant_category:delete", + "store:create", + "store:read", + "store:update", + "store:delete", + "store-table-area:read", + "store-table-area:create", + "store-table-area:update", + "store-table-area:delete", + "store-table:read", + "store-table:create", + "store-table:update", + "store-table:delete", + "store-table:export", + "store-staff:read", + "store-staff:create", + "store-staff:update", + "store-staff:delete", + "store-shift:read", + "store-shift:create", + "store-shift:update", + "store-shift:delete", + "product:create", + "product:read", + "product:update", + "product:delete", + "product:publish", + "product-sku:read", + "product-sku:update", + "product-attr:read", + "product-attr:update", + "product-addon:read", + "product-addon:update", + "product-media:read", + "product-media:update", + "product-pricing:read", + "product-pricing:update", + "order:create", + "order:read", + "order:update", + "order:delete", + "payment:create", + "payment:read", + "payment:update", + "payment:delete", + "delivery:create", + "delivery:read", + "delivery:update", + "delivery:delete", + "dictionary:group:read", + "dictionary:group:create", + "dictionary:group:update", + "dictionary:group:delete", + "dictionary:item:create", + "dictionary:item:update", + "dictionary:item:delete", + "system-parameter:create", + "system-parameter:read", + "system-parameter:update", + "system-parameter:delete" + ] + }, + { + "TemplateCode": "tenant-admin", + "Name": "租户管理员", + "Description": "管理本租户的门店、商品、订单与权限", + "IsActive": true, + "Permissions": [ + "identity:profile:read", + "identity:role:read", + "identity:role:create", + "identity:role:update", + "identity:role:delete", + "identity:role:bind-permission", + "identity:permission:read", + "identity:permission:create", + "identity:permission:update", + "identity:permission:delete", + "tenant-bill:read", + "tenant-bill:create", + "tenant-bill:pay", + "tenant-announcement:read", + "tenant-announcement:create", + "tenant-announcement:update", + "tenant-announcement:delete", + "tenant-notification:read", + "tenant-notification:update", + "tenant:read", + "tenant:subscription", + "tenant:quota:check", + "merchant:read", + "merchant:update", + "merchant_category:read", + "merchant_category:create", + "merchant_category:update", + "merchant_category:delete", + "store:create", + "store:read", + "store:update", + "store:delete", + "store-table-area:read", + "store-table-area:create", + "store-table-area:update", + "store-table-area:delete", + "store-table:read", + "store-table:create", + "store-table:update", + "store-table:delete", + "store-table:export", + "store-staff:read", + "store-staff:create", + "store-staff:update", + "store-staff:delete", + "store-shift:read", + "store-shift:create", + "store-shift:update", + "store-shift:delete", + "product:create", + "product:read", + "product:update", + "product:delete", + "product:publish", + "product-sku:read", + "product-sku:update", + "product-attr:read", + "product-attr:update", + "product-addon:read", + "product-addon:update", + "product-media:read", + "product-media:update", + "product-pricing:read", + "product-pricing:update", + "inventory:read", + "inventory:adjust", + "inventory:lock", + "inventory:release", + "inventory:deduct", + "inventory:batch:read", + "inventory:batch:update", + "inventory:lock:expire", + "order:create", + "order:read", + "order:update", + "delivery:create", + "delivery:read", + "delivery:update", + "payment:create", + "payment:read", + "payment:update", + "dictionary:group:read", + "dictionary:group:create", + "dictionary:group:update", + "dictionary:group:delete", + "dictionary:item:create", + "dictionary:item:update", + "dictionary:item:delete", + "system-parameter:read" + ] + }, + { + "TemplateCode": "store-manager", + "Name": "店长", + "Description": "负责门店运营与商品、订单管理", + "IsActive": true, + "Permissions": [ + "identity:profile:read", + "store:read", + "store:update", + "store-table-area:read", + "store-table-area:create", + "store-table-area:update", + "store-table-area:delete", + "store-table:read", + "store-table:create", + "store-table:update", + "store-table:export", + "store-staff:read", + "store-staff:create", + "store-staff:update", + "store-shift:read", + "store-shift:create", + "store-shift:update", + "product:create", + "product:read", + "product:update", + "product:publish", + "product-sku:read", + "product-sku:update", + "product-attr:read", + "product-attr:update", + "product-addon:read", + "product-addon:update", + "product-media:read", + "product-media:update", + "product-pricing:read", + "product-pricing:update", + "inventory:read", + "inventory:adjust", + "inventory:lock", + "inventory:release", + "inventory:deduct", + "inventory:batch:read", + "inventory:batch:update", + "inventory:lock:expire", + "pickup-setting:read", + "pickup-setting:update", + "pickup-slot:read", + "pickup-slot:create", + "pickup-slot:update", + "pickup-slot:delete", + "order:create", + "order:read", + "order:update", + "delivery:read", + "delivery:update", + "payment:read", + "payment:update", + "dictionary:group:read", + "dictionary:item:create", + "dictionary:item:update", + "dictionary:item:delete" + ] + }, + { + "TemplateCode": "store-staff", + "Name": "店员", + "Description": "处理订单履约与收款查询", + "IsActive": true, + "Permissions": [ + "identity:profile:read", + "store:read", + "store-table-area:read", + "store-table:read", + "store-shift:read", + "product:read", + "order:read", + "order:update", + "delivery:read", + "payment:read" + ] + } + ], "Users": [ { "Account": "admin", @@ -46,7 +335,105 @@ "Password": "Admin@123456", "TenantId": 1000000000001, "Roles": [ "PlatformAdmin" ], - "Permissions": [ "merchant:*", "store:*", "product:*", "order:*", "payment:*", "delivery:*" ] + "Permissions": [ + "identity:profile:read", + "identity:role:read", + "identity:role:create", + "identity:role:update", + "identity:role:delete", + "identity:role:bind-permission", + "identity:permission:read", + "identity:permission:create", + "identity:permission:update", + "identity:permission:delete", + "role-template:read", + "role-template:create", + "role-template:update", + "role-template:delete", + "tenant-bill:read", + "tenant-bill:create", + "tenant-bill:pay", + "tenant-announcement:read", + "tenant-announcement:create", + "tenant-announcement:update", + "tenant-announcement:delete", + "tenant-notification:read", + "tenant-notification:update", + "tenant:create", + "tenant:read", + "tenant:review", + "tenant:subscription", + "tenant:quota:check", + "tenant-package:read", + "tenant-package:create", + "tenant-package:update", + "tenant-package:delete", + "merchant:create", + "merchant:read", + "merchant:update", + "merchant:delete", + "merchant:review", + "merchant_category:read", + "merchant_category:create", + "merchant_category:update", + "merchant_category:delete", + "store:create", + "store:read", + "store:update", + "store:delete", + "product:create", + "product:read", + "product:update", + "product:delete", + "product:publish", + "product-sku:read", + "product-sku:update", + "product-attr:read", + "product-attr:update", + "product-addon:read", + "product-addon:update", + "product-media:read", + "product-media:update", + "product-pricing:read", + "product-pricing:update", + "inventory:read", + "inventory:adjust", + "inventory:lock", + "inventory:release", + "inventory:deduct", + "inventory:batch:read", + "inventory:batch:update", + "inventory:lock:expire", + "pickup-setting:read", + "pickup-setting:update", + "pickup-slot:read", + "pickup-slot:create", + "pickup-slot:update", + "pickup-slot:delete", + "order:create", + "order:read", + "order:update", + "order:delete", + "payment:create", + "payment:read", + "payment:update", + "payment:delete", + "delivery:create", + "delivery:read", + "delivery:update", + "delivery:delete", + "dictionary:group:read", + "dictionary:group:create", + "dictionary:group:update", + "dictionary:group:delete", + "dictionary:item:create", + "dictionary:item:update", + "dictionary:item:delete", + "system-parameter:create", + "system-parameter:read", + "system-parameter:update", + "system-parameter:delete" + ] } ] } diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs index 332dec2..11e7d10 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs @@ -10,38 +10,46 @@ namespace TakeoutSaaS.MiniApi.Controllers; /// /// 小程序登录认证 /// -/// -/// 小程序登录认证 -/// -/// +/// 提供小程序端的微信登录与 Token 刷新能力。 +/// 小程序认证服务 [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) { + // 1. 调用认证服务完成微信登录 var response = await authService.LoginWithWeChatAsync(request, cancellationToken); + + // 2. 返回访问与刷新令牌 return ApiResponse.Ok(response); } /// /// 刷新 Token /// + /// 刷新令牌请求。 + /// 取消标记。 + /// 新的访问令牌与刷新令牌。 [HttpPost("refresh")] [AllowAnonymous] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public async Task> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) { + // 1. 调用认证服务刷新 Token var response = await authService.RefreshTokenAsync(request, cancellationToken); + + // 2. 返回新的令牌 return ApiResponse.Ok(response); } } diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs index a795c9e..0d651fb 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/FilesController.cs @@ -1,6 +1,4 @@ -using System.Linq; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Storage.Abstractions; using TakeoutSaaS.Application.Storage.Contracts; @@ -19,34 +17,41 @@ namespace TakeoutSaaS.MiniApi.Controllers; [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) { + // 1. 校验文件有效性 if (file == null || file.Length == 0) { return ApiResponse.Error(ErrorCodes.BadRequest, "文件不能为空"); } + // 2. 解析上传类型 if (!UploadFileTypeParser.TryParse(type, out var uploadType)) { return ApiResponse.Error(ErrorCodes.BadRequest, "上传类型不合法"); } + // 3. 提取请求来源 var origin = Request.Headers["Origin"].FirstOrDefault() ?? Request.Headers["Referer"].FirstOrDefault(); await using var stream = file.OpenReadStream(); - var result = await _fileStorageService.UploadAsync( + // 4. 调用存储服务执行上传 + var result = await fileStorageService.UploadAsync( new UploadFileRequest(uploadType, stream, file.FileName, file.ContentType ?? string.Empty, file.Length, origin), cancellationToken); + // 5. 返回上传结果 return ApiResponse.Ok(result); } } diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs index c2673f8..e19775c 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs @@ -1,6 +1,4 @@ -using System; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Shared.Abstractions.Results; using TakeoutSaaS.Shared.Web.Api; @@ -23,7 +21,10 @@ public class HealthController : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public ApiResponse Get() { + // 1. 构造健康状态 var payload = new { status = "OK", service = "MiniApi", time = DateTime.UtcNow }; + + // 2. 返回健康响应 return ApiResponse.Ok(payload); } } diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs index 4bd29e4..7411644 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs @@ -1,8 +1,4 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; @@ -16,31 +12,31 @@ 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) { + // 1. 从 JWT 中解析用户标识 var userId = User.GetUserId(); if (userId == 0) { return ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"); } + // 2. 查询用户档案并返回 var profile = await authService.GetProfileAsync(userId, cancellationToken); return ApiResponse.Ok(profile); } diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs new file mode 100644 index 0000000..549db99 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs @@ -0,0 +1,38 @@ +using System; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Application.App.Products.Queries; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.MiniApi.Controllers; + +/// +/// 小程序端菜单查询。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/mini/v{version:apiVersion}/stores/{storeId:long}/menu")] +public sealed class MenusController(IMediator mediator) : BaseApiController +{ + /// + /// 获取门店菜单(含分类与商品详情)。 + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> GetMenu(long storeId, [FromQuery] DateTime? updatedAfter, CancellationToken cancellationToken) + { + // 1. 组装查询 + var query = new GetStoreMenuQuery + { + StoreId = storeId, + UpdatedAfter = updatedAfter + }; + // 2. 拉取菜单 + var result = await mediator.Send(query, cancellationToken); + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/PickupSlotsController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/PickupSlotsController.cs new file mode 100644 index 0000000..66ce98d --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/PickupSlotsController.cs @@ -0,0 +1,31 @@ +using System; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.MiniApi.Controllers; + +/// +/// 小程序端自提档期查询。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/mini/v{version:apiVersion}/stores/{storeId:long}/pickup-slots")] +public sealed class PickupSlotsController(IMediator mediator) : BaseApiController +{ + /// + /// 获取指定日期可用档期。 + /// + [HttpGet] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> GetSlots(long storeId, [FromQuery] DateTime date, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAvailablePickupSlotsQuery { StoreId = storeId, Date = date }, cancellationToken); + return ApiResponse>.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/TablesController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/TablesController.cs new file mode 100644 index 0000000..504e35a --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/TablesController.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.MiniApi.Controllers; + +/// +/// 桌码上下文。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/mini/v{version:apiVersion}/tables")] +public sealed class TablesController(IMediator mediator) : BaseApiController +{ + /// + /// 解析桌码并返回上下文。 + /// + [HttpGet("{code}/context")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> GetContext(string code, CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetStoreTableContextQuery { TableCode = code }, cancellationToken); + return result is null + ? ApiResponse.Error(ErrorCodes.NotFound, "桌码不存在") + : ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs index 3c5adac..8369004 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Program.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs @@ -1,7 +1,4 @@ -using System; -using System.Linq; using Microsoft.AspNetCore.Cors.Infrastructure; -using Microsoft.Extensions.Configuration; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -18,9 +15,11 @@ using TakeoutSaaS.Shared.Kernel.Ids; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; +// 1. 创建构建器与日志模板 var builder = WebApplication.CreateBuilder(args); const string logTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [TraceId:{TraceId}] [SpanId:{SpanId}] [Service:{Service}] {SourceContext} {Message:lj}{NewLine}{Exception}"; +// 2. 注册雪花 ID 生成器与 Serilog builder.Services.AddSingleton(_ => new SnowflakeIdGenerator()); builder.Host.UseSerilog((_, _, configuration) => { @@ -36,6 +35,7 @@ builder.Host.UseSerilog((_, _, configuration) => outputTemplate: logTemplate); }); +// 3. 注册通用 Web 能力与 Swagger builder.Services.AddSharedWebCore(); builder.Services.AddSharedSwagger(options => { @@ -43,6 +43,8 @@ builder.Services.AddSharedSwagger(options => options.Description = "小程序 API 文档"; options.EnableAuthorization = true; }); + +// 4. 注册多租户与业务模块 builder.Services.AddTenantResolution(builder.Configuration); builder.Services.AddStorageModule(builder.Configuration); builder.Services.AddStorageApplication(); @@ -51,6 +53,8 @@ builder.Services.AddSmsApplication(builder.Configuration); builder.Services.AddMessagingModule(builder.Configuration); builder.Services.AddMessagingApplication(); builder.Services.AddHealthChecks(); + +// 5. 配置 OpenTelemetry 采集 var otelSection = builder.Configuration.GetSection("Otel"); var otelEndpoint = otelSection.GetValue("Endpoint"); var useConsoleExporter = otelSection.GetValue("UseConsoleExporter") ?? builder.Environment.IsDevelopment(); @@ -102,6 +106,7 @@ builder.Services.AddOpenTelemetry() } }); +// 6. 配置 CORS var miniOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Mini"); builder.Services.AddCors(options => { @@ -111,6 +116,7 @@ builder.Services.AddCors(options => }); }); +// 7. 构建应用并配置中间件管道 var app = builder.Build(); app.UseCors("MiniApiCors"); @@ -123,6 +129,7 @@ app.MapPrometheusScrapingEndpoint(); app.MapControllers(); app.Run(); +// 8. 解析配置中的 CORS 域名 static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey) { var origins = configuration.GetSection(sectionKey).Get(); @@ -132,6 +139,7 @@ static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionK .ToArray() ?? []; } +// 9. 构建 CORS 策略 static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins) { if (origins.Length == 0) diff --git a/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs index e1fa6d5..08fe92b 100644 --- a/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs +++ b/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs @@ -1,6 +1,4 @@ -using System; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using TakeoutSaaS.Shared.Abstractions.Results; using TakeoutSaaS.Shared.Web.Api; @@ -23,7 +21,10 @@ public class HealthController : BaseApiController [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] public ApiResponse Get() { + // 1. 构造健康状态 var payload = new { status = "OK", service = "UserApi", time = DateTime.UtcNow }; + + // 2. 返回健康响应 return ApiResponse.Ok(payload); } } diff --git a/src/Api/TakeoutSaaS.UserApi/Program.cs b/src/Api/TakeoutSaaS.UserApi/Program.cs index 5c93992..d59e501 100644 --- a/src/Api/TakeoutSaaS.UserApi/Program.cs +++ b/src/Api/TakeoutSaaS.UserApi/Program.cs @@ -1,7 +1,4 @@ -using System; -using System.Linq; using Microsoft.AspNetCore.Cors.Infrastructure; -using Microsoft.Extensions.Configuration; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -12,9 +9,11 @@ using TakeoutSaaS.Shared.Kernel.Ids; using TakeoutSaaS.Shared.Web.Extensions; using TakeoutSaaS.Shared.Web.Swagger; +// 1. 创建构建器与日志模板 var builder = WebApplication.CreateBuilder(args); const string logTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [TraceId:{TraceId}] [SpanId:{SpanId}] [Service:{Service}] {SourceContext} {Message:lj}{NewLine}{Exception}"; +// 2. 注册雪花 ID 生成器与 Serilog builder.Services.AddSingleton(_ => new SnowflakeIdGenerator()); builder.Host.UseSerilog((_, _, configuration) => { @@ -30,6 +29,7 @@ builder.Host.UseSerilog((_, _, configuration) => outputTemplate: logTemplate); }); +// 3. 注册通用 Web 能力与 Swagger builder.Services.AddSharedWebCore(); builder.Services.AddSharedSwagger(options => { @@ -37,8 +37,12 @@ builder.Services.AddSharedSwagger(options => options.Description = "C 端用户 API 文档"; options.EnableAuthorization = true; }); + +// 4. 注册多租户与健康检查 builder.Services.AddTenantResolution(builder.Configuration); builder.Services.AddHealthChecks(); + +// 5. 配置 OpenTelemetry 采集 var otelSection = builder.Configuration.GetSection("Otel"); var otelEndpoint = otelSection.GetValue("Endpoint"); var useConsoleExporter = otelSection.GetValue("UseConsoleExporter") ?? builder.Environment.IsDevelopment(); @@ -90,6 +94,7 @@ builder.Services.AddOpenTelemetry() } }); +// 6. 配置 CORS var userOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:User"); builder.Services.AddCors(options => { @@ -99,6 +104,7 @@ builder.Services.AddCors(options => }); }); +// 7. 构建应用并配置中间件管道 var app = builder.Build(); app.UseCors("UserApiCors"); @@ -111,6 +117,7 @@ app.MapPrometheusScrapingEndpoint(); app.MapControllers(); app.Run(); +// 8. 解析配置中的 CORS 域名 static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey) { var origins = configuration.GetSection(sectionKey).Get(); @@ -120,6 +127,7 @@ static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionK .ToArray() ?? []; } +// 9. 构建 CORS 策略 static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins) { if (origins.Length == 0) diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/CreateDeliveryOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/CreateDeliveryOrderCommandHandler.cs index 1a67240..ad4149f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/CreateDeliveryOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/CreateDeliveryOrderCommandHandler.cs @@ -13,12 +13,10 @@ namespace TakeoutSaaS.Application.App.Deliveries.Handlers; public sealed class CreateDeliveryOrderCommandHandler(IDeliveryRepository deliveryRepository, ILogger logger) : IRequestHandler { - private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; - private readonly ILogger _logger = logger; - /// public async Task Handle(CreateDeliveryOrderCommand request, CancellationToken cancellationToken) { + // 1. 构建配送单实体 var deliveryOrder = new DeliveryOrder { OrderId = request.OrderId, @@ -34,10 +32,14 @@ public sealed class CreateDeliveryOrderCommandHandler(IDeliveryRepository delive FailureReason = request.FailureReason?.Trim() }; - await _deliveryRepository.AddDeliveryOrderAsync(deliveryOrder, cancellationToken); - await _deliveryRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("创建配送单 {DeliveryOrderId} 对应订单 {OrderId}", deliveryOrder.Id, deliveryOrder.OrderId); + // 2. 持久化配送单 + await deliveryRepository.AddDeliveryOrderAsync(deliveryOrder, cancellationToken); + await deliveryRepository.SaveChangesAsync(cancellationToken); + // 3. 记录日志 + logger.LogInformation("创建配送单 {DeliveryOrderId} 对应订单 {OrderId}", deliveryOrder.Id, deliveryOrder.OrderId); + + // 4. 映射 DTO 返回 return MapToDto(deliveryOrder, []); } diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/DeleteDeliveryOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/DeleteDeliveryOrderCommandHandler.cs index 40974f1..308c942 100644 --- a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/DeleteDeliveryOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/DeleteDeliveryOrderCommandHandler.cs @@ -15,23 +15,23 @@ public sealed class DeleteDeliveryOrderCommandHandler( ILogger logger) : IRequestHandler { - private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(DeleteDeliveryOrderCommand request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); + // 1. 获取租户并定位配送单 + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); if (existing == null) { return false; } - await _deliveryRepository.DeleteDeliveryOrderAsync(request.DeliveryOrderId, tenantId, cancellationToken); - await _deliveryRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("删除配送单 {DeliveryOrderId}", request.DeliveryOrderId); + // 2. 删除并保存 + await deliveryRepository.DeleteDeliveryOrderAsync(request.DeliveryOrderId, tenantId, cancellationToken); + await deliveryRepository.SaveChangesAsync(cancellationToken); + + // 3. 记录删除日志 + logger.LogInformation("删除配送单 {DeliveryOrderId}", request.DeliveryOrderId); return true; } diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/GetDeliveryOrderByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/GetDeliveryOrderByIdQueryHandler.cs index 24e425f..d863d4b 100644 --- a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/GetDeliveryOrderByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/GetDeliveryOrderByIdQueryHandler.cs @@ -15,20 +15,23 @@ public sealed class GetDeliveryOrderByIdQueryHandler( ITenantProvider tenantProvider) : IRequestHandler { - private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task Handle(GetDeliveryOrderByIdQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var order = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); + // 1. 读取当前租户标识 + var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 查询配送单主体 + var order = await deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); if (order == null) { return null; } - var events = await _deliveryRepository.GetEventsAsync(order.Id, tenantId, cancellationToken); + // 3. 查询配送事件明细 + var events = await deliveryRepository.GetEventsAsync(order.Id, tenantId, cancellationToken); + + // 4. 映射为 DTO 返回 return MapToDto(order, events); } diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/SearchDeliveryOrdersQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/SearchDeliveryOrdersQueryHandler.cs index cc748db..7fcdd5a 100644 --- a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/SearchDeliveryOrdersQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/SearchDeliveryOrdersQueryHandler.cs @@ -15,21 +15,25 @@ public sealed class SearchDeliveryOrdersQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task> Handle(SearchDeliveryOrdersQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var orders = await _deliveryRepository.SearchAsync(tenantId, request.Status, request.OrderId, cancellationToken); + // 1. 获取当前租户标识 + var tenantId = tenantProvider.GetCurrentTenantId(); + // 2. 查询配送单列表(租户隔离) + var orders = await deliveryRepository.SearchAsync(tenantId, request.Status, request.OrderId, cancellationToken); + + // 3. 本地排序 var sorted = ApplySorting(orders, request.SortBy, request.SortDescending); + + // 4. 本地分页 var paged = sorted .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 5. 映射 DTO var items = paged.Select(order => new DeliveryOrderDto { Id = order.Id, @@ -48,6 +52,7 @@ public sealed class SearchDeliveryOrdersQueryHandler( CreatedAt = order.CreatedAt }).ToList(); + // 6. 返回分页结果 return new PagedResult(items, request.Page, request.PageSize, orders.Count); } diff --git a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/UpdateDeliveryOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/UpdateDeliveryOrderCommandHandler.cs index c8df929..0c3f2c2 100644 --- a/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/UpdateDeliveryOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Deliveries/Handlers/UpdateDeliveryOrderCommandHandler.cs @@ -17,20 +17,20 @@ public sealed class UpdateDeliveryOrderCommandHandler( ILogger logger) : IRequestHandler { - private readonly IDeliveryRepository _deliveryRepository = deliveryRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(UpdateDeliveryOrderCommand request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); + // 1. 获取当前租户标识 + var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 查询目标配送单 + var existing = await deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken); if (existing == null) { return null; } + // 3. 更新字段 existing.OrderId = request.OrderId; existing.Provider = request.Provider; existing.ProviderOrderId = request.ProviderOrderId?.Trim(); @@ -43,11 +43,15 @@ public sealed class UpdateDeliveryOrderCommandHandler( existing.DeliveredAt = request.DeliveredAt; existing.FailureReason = request.FailureReason?.Trim(); - await _deliveryRepository.UpdateDeliveryOrderAsync(existing, cancellationToken); - await _deliveryRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("更新配送单 {DeliveryOrderId}", existing.Id); + // 4. 持久化变更 + await deliveryRepository.UpdateDeliveryOrderAsync(existing, cancellationToken); + await deliveryRepository.SaveChangesAsync(cancellationToken); - var events = await _deliveryRepository.GetEventsAsync(existing.Id, tenantId, cancellationToken); + // 5. 记录更新日志 + logger.LogInformation("更新配送单 {DeliveryOrderId}", existing.Id); + + // 6. 查询事件并返回映射结果 + var events = await deliveryRepository.GetEventsAsync(existing.Id, tenantId, cancellationToken); return MapToDto(existing, events); } diff --git a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs index 62284b1..c4af1ce 100644 --- a/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs +++ b/src/Application/TakeoutSaaS.Application/App/Extensions/AppApplicationServiceCollectionExtensions.cs @@ -1,7 +1,7 @@ -using System.Reflection; using FluentValidation; using MediatR; using Microsoft.Extensions.DependencyInjection; +using System.Reflection; using TakeoutSaaS.Application.App.Common.Behaviors; namespace TakeoutSaaS.Application.App.Extensions; diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/AdjustInventoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/AdjustInventoryCommand.cs new file mode 100644 index 0000000..7ee93f7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/AdjustInventoryCommand.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Enums; + +namespace TakeoutSaaS.Application.App.Inventory.Commands; + +/// +/// 库存调整命令。 +/// +public sealed record AdjustInventoryCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } + + /// + /// 调整数量,正数入库,负数出库。 + /// + public int QuantityDelta { get; init; } + + /// + /// 调整类型。 + /// + public InventoryAdjustmentType AdjustmentType { get; init; } = InventoryAdjustmentType.Manual; + + /// + /// 原因说明。 + /// + public string? Reason { get; init; } + + /// + /// 安全库存阈值(可选)。 + /// + public int? SafetyStock { get; init; } + + /// + /// 是否售罄标记。 + /// + public bool? IsSoldOut { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/DeductInventoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/DeductInventoryCommand.cs new file mode 100644 index 0000000..d97026f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/DeductInventoryCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; + +namespace TakeoutSaaS.Application.App.Inventory.Commands; + +/// +/// 扣减库存命令(履约/支付成功)。 +/// +public sealed record DeductInventoryCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } + + /// + /// 扣减数量。 + /// + public int Quantity { get; init; } + + /// + /// 是否预售锁定转扣减。 + /// + public bool IsPresaleOrder { get; init; } + + /// + /// 幂等键(与锁定请求一致可避免重复扣减)。 + /// + public string? IdempotencyKey { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/LockInventoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/LockInventoryCommand.cs new file mode 100644 index 0000000..f19754b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/LockInventoryCommand.cs @@ -0,0 +1,41 @@ +using System; +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; + +namespace TakeoutSaaS.Application.App.Inventory.Commands; + +/// +/// 锁定库存命令。 +/// +public sealed record LockInventoryCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } + + /// + /// 锁定数量。 + /// + public int Quantity { get; init; } + + /// + /// 是否按预售逻辑锁定。 + /// + public bool IsPresaleOrder { get; init; } + + /// + /// 锁定过期时间(UTC),超时可释放。 + /// + public DateTime? ExpiresAt { get; init; } + + /// + /// 幂等键(同一键重复调用返回同一结果)。 + /// + public string IdempotencyKey { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseExpiredInventoryLocksCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseExpiredInventoryLocksCommand.cs new file mode 100644 index 0000000..a721448 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseExpiredInventoryLocksCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Inventory.Commands; + +/// +/// 释放过期库存锁定命令。 +/// +public sealed record ReleaseExpiredInventoryLocksCommand : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseInventoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseInventoryCommand.cs new file mode 100644 index 0000000..dd7c889 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/ReleaseInventoryCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; + +namespace TakeoutSaaS.Application.App.Inventory.Commands; + +/// +/// 释放库存命令。 +/// +public sealed record ReleaseInventoryCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } + + /// + /// 释放数量。 + /// + public int Quantity { get; init; } + + /// + /// 是否预售锁定释放。 + /// + public bool IsPresaleOrder { get; init; } + + /// + /// 幂等键(与锁定请求一致可避免重复释放)。 + /// + public string? IdempotencyKey { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/UpsertInventoryBatchCommand.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/UpsertInventoryBatchCommand.cs new file mode 100644 index 0000000..8943014 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Commands/UpsertInventoryBatchCommand.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; + +namespace TakeoutSaaS.Application.App.Inventory.Commands; + +/// +/// 新增或更新库存批次命令。 +/// +public sealed record UpsertInventoryBatchCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } + + /// + /// 批次号。 + /// + public string BatchNumber { get; init; } = string.Empty; + + /// + /// 生产日期。 + /// + public DateTime? ProductionDate { get; init; } + + /// + /// 过期日期。 + /// + public DateTime? ExpireDate { get; init; } + + /// + /// 入库数量。 + /// + public int Quantity { get; init; } + + /// + /// 剩余数量。 + /// + public int RemainingQuantity { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryBatchDto.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryBatchDto.cs new file mode 100644 index 0000000..eed3c53 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryBatchDto.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Inventory.Dto; + +/// +/// 库存批次 DTO。 +/// +public sealed record InventoryBatchDto +{ + /// + /// 批次 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductSkuId { get; init; } + + /// + /// 批次号。 + /// + public string BatchNumber { get; init; } = string.Empty; + + /// + /// 生产日期。 + /// + public DateTime? ProductionDate { get; init; } + + /// + /// 过期日期。 + /// + public DateTime? ExpireDate { get; init; } + + /// + /// 入库数量。 + /// + public int Quantity { get; init; } + + /// + /// 剩余数量。 + /// + public int RemainingQuantity { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryItemDto.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryItemDto.cs new file mode 100644 index 0000000..f02902a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Dto/InventoryItemDto.cs @@ -0,0 +1,99 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Inventory.Dto; + +/// +/// 库存项 DTO。 +/// +public sealed record InventoryItemDto +{ + /// + /// 库存记录 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductSkuId { get; init; } + + /// + /// 批次号。 + /// + public string? BatchNumber { get; init; } + + /// + /// 可用库存。 + /// + public int QuantityOnHand { get; init; } + + /// + /// 已锁定库存。 + /// + public int QuantityReserved { get; init; } + + /// + /// 安全库存。 + /// + public int? SafetyStock { get; init; } + + /// + /// 储位。 + /// + public string? Location { get; init; } + + /// + /// 过期日期。 + /// + public DateTime? ExpireDate { get; init; } + + /// + /// 是否预售。 + /// + public bool IsPresale { get; init; } + + /// + /// 预售开始时间。 + /// + public DateTime? PresaleStartTime { get; init; } + + /// + /// 预售结束时间。 + /// + public DateTime? PresaleEndTime { get; init; } + + /// + /// 预售上限。 + /// + public int? PresaleCapacity { get; init; } + + /// + /// 已锁定预售量。 + /// + public int PresaleLocked { get; init; } + + /// + /// 限购数量。 + /// + public int? MaxQuantityPerOrder { get; init; } + + /// + /// 是否售罄。 + /// + public bool IsSoldOut { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/AdjustInventoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/AdjustInventoryCommandHandler.cs new file mode 100644 index 0000000..0590589 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/AdjustInventoryCommandHandler.cs @@ -0,0 +1,85 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Entities; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 库存调整处理器。 +/// +public sealed class AdjustInventoryCommandHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(AdjustInventoryCommand request, CancellationToken cancellationToken) + { + // 1. 读取库存 + var tenantId = tenantProvider.GetCurrentTenantId(); + var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken); + // 2. 初始化或校验存在性 + if (item is null) + { + if (request.QuantityDelta < 0) + { + throw new BusinessException(ErrorCodes.NotFound, "库存不存在,无法扣减"); + } + + // 初始化库存记录 + item = new InventoryItem + { + TenantId = tenantId, + StoreId = request.StoreId, + ProductSkuId = request.ProductSkuId, + QuantityOnHand = request.QuantityDelta, + QuantityReserved = 0, + SafetyStock = request.SafetyStock, + IsSoldOut = false + }; + await inventoryRepository.AddItemAsync(item, cancellationToken); + } + + // 3. 应用调整 + var newQuantity = item.QuantityOnHand + request.QuantityDelta; + if (newQuantity < 0) + { + throw new BusinessException(ErrorCodes.Conflict, "库存不足,无法扣减"); + } + + item.QuantityOnHand = newQuantity; + item.SafetyStock = request.SafetyStock ?? item.SafetyStock; + item.IsSoldOut = request.IsSoldOut ?? IsSoldOut(item); + + // 4. 写入调整记录 + var adjustment = new InventoryAdjustment + { + TenantId = tenantId, + InventoryItemId = item.Id, + AdjustmentType = request.AdjustmentType, + Quantity = request.QuantityDelta, + Reason = request.Reason, + OperatorId = null, + OccurredAt = DateTime.UtcNow + }; + await inventoryRepository.AddAdjustmentAsync(adjustment, cancellationToken); + await inventoryRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("调整库存 SKU {ProductSkuId} 门店 {StoreId} 变更 {Delta}", request.ProductSkuId, request.StoreId, request.QuantityDelta); + return InventoryMapping.ToDto(item); + } + + // 辅助:售罄判定 + private static bool IsSoldOut(InventoryItem item) + { + var available = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked; + var safety = item.SafetyStock ?? 0; + return available <= safety || item.QuantityOnHand <= 0; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/DeductInventoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/DeductInventoryCommandHandler.cs new file mode 100644 index 0000000..0cbde90 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/DeductInventoryCommandHandler.cs @@ -0,0 +1,110 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 库存扣减处理器。 +/// +public sealed class DeductInventoryCommandHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeductInventoryCommand request, CancellationToken cancellationToken) + { + // 1. 读取库存 + var tenantId = tenantProvider.GetCurrentTenantId(); + var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken); + if (item is null) + { + throw new BusinessException(ErrorCodes.NotFound, "库存不存在"); + } + + // 1.1 幂等:若锁记录已扣减/释放则直接返回 + if (!string.IsNullOrWhiteSpace(request.IdempotencyKey)) + { + var lockRecord = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken); + if (lockRecord is not null) + { + if (lockRecord.Status == Domain.Inventory.Enums.InventoryLockStatus.Deducted) + { + return InventoryMapping.ToDto(item); + } + + if (lockRecord.Status == Domain.Inventory.Enums.InventoryLockStatus.Locked) + { + request = request with { Quantity = lockRecord.Quantity, IsPresaleOrder = lockRecord.IsPresale }; + await inventoryRepository.MarkLockStatusAsync(lockRecord, Domain.Inventory.Enums.InventoryLockStatus.Deducted, cancellationToken); + } + } + } + + // 2. 计算扣减来源 + var isPresale = request.IsPresaleOrder || item.IsPresale; + if (isPresale) + { + if (item.PresaleLocked < request.Quantity) + { + throw new BusinessException(ErrorCodes.Conflict, "预售锁定不足,无法扣减"); + } + + item.PresaleLocked -= request.Quantity; + } + else + { + if (item.QuantityReserved < request.Quantity) + { + throw new BusinessException(ErrorCodes.Conflict, "锁定库存不足,无法扣减"); + } + + item.QuantityReserved -= request.Quantity; + } + + var remaining = item.QuantityOnHand - request.Quantity; + if (remaining < 0) + { + throw new BusinessException(ErrorCodes.Conflict, "可用库存不足,无法扣减"); + } + + // 3. 扣减可用量并按批次消耗 + item.QuantityOnHand = remaining; + // 3.1 批次扣减(非预售) + if (!isPresale) + { + var batches = await inventoryRepository.GetBatchesForConsumeAsync(tenantId, request.StoreId, request.ProductSkuId, item.BatchConsumeStrategy, cancellationToken); + var need = request.Quantity; + foreach (var batch in batches) + { + if (need <= 0) + { + break; + } + + var take = Math.Min(batch.RemainingQuantity, need); + batch.RemainingQuantity -= take; + need -= take; + await inventoryRepository.UpdateBatchAsync(batch, cancellationToken); + } + + if (need > 0) + { + throw new BusinessException(ErrorCodes.Conflict, "批次数量不足,无法扣减"); + } + } + + item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0); + await inventoryRepository.UpdateItemAsync(item, cancellationToken); + await inventoryRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("扣减库存 门店 {StoreId} SKU {ProductSkuId} 数量 {Quantity}", request.StoreId, request.ProductSkuId, request.Quantity); + return InventoryMapping.ToDto(item); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryBatchesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryBatchesQueryHandler.cs new file mode 100644 index 0000000..3796fda --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryBatchesQueryHandler.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Application.App.Inventory.Queries; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 库存批次查询处理器。 +/// +public sealed class GetInventoryBatchesQueryHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(GetInventoryBatchesQuery request, CancellationToken cancellationToken) + { + // 1. 读取批次 + var tenantId = tenantProvider.GetCurrentTenantId(); + var batches = await inventoryRepository.GetBatchesAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken); + // 2. 映射 + return batches.Select(InventoryMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryItemQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryItemQueryHandler.cs new file mode 100644 index 0000000..f9a940c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/GetInventoryItemQueryHandler.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Application.App.Inventory.Queries; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 查询库存处理器。 +/// +public sealed class GetInventoryItemQueryHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(GetInventoryItemQuery request, CancellationToken cancellationToken) + { + // 1. 读取库存 + var tenantId = tenantProvider.GetCurrentTenantId(); + var item = await inventoryRepository.FindBySkuAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken); + // 2. 返回 DTO + return item is null ? null : InventoryMapping.ToDto(item); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/LockInventoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/LockInventoryCommandHandler.cs new file mode 100644 index 0000000..42f69fa --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/LockInventoryCommandHandler.cs @@ -0,0 +1,92 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 库存锁定处理器。 +/// +public sealed class LockInventoryCommandHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(LockInventoryCommand request, CancellationToken cancellationToken) + { + // 1. 读取库存 + var tenantId = tenantProvider.GetCurrentTenantId(); + var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken); + if (item is null) + { + throw new BusinessException(ErrorCodes.NotFound, "库存不存在"); + } + + // 1.1 幂等处理 + var existingLock = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken); + if (existingLock is not null) + { + return InventoryMapping.ToDto(item); + } + + // 2. 校验可用量 + var now = DateTime.UtcNow; + var isPresale = request.IsPresaleOrder || item.IsPresale; + if (isPresale) + { + if (item.PresaleStartTime.HasValue && now < item.PresaleStartTime.Value) + { + throw new BusinessException(ErrorCodes.Conflict, "预售尚未开始"); + } + + if (item.PresaleEndTime.HasValue && now > item.PresaleEndTime.Value) + { + throw new BusinessException(ErrorCodes.Conflict, "预售已结束"); + } + } + + var available = isPresale + ? (item.PresaleCapacity ?? item.QuantityOnHand) - item.PresaleLocked + : item.QuantityOnHand - item.QuantityReserved; + if (available < request.Quantity) + { + throw new BusinessException(ErrorCodes.Conflict, "库存不足,无法锁定"); + } + + // 3. 执行锁定 + if (isPresale) + { + item.PresaleLocked += request.Quantity; + } + else + { + item.QuantityReserved += request.Quantity; + } + + item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0); + await inventoryRepository.UpdateItemAsync(item, cancellationToken); + var lockRecord = new Domain.Inventory.Entities.InventoryLockRecord + { + TenantId = tenantId, + StoreId = request.StoreId, + ProductSkuId = request.ProductSkuId, + Quantity = request.Quantity, + IsPresale = isPresale, + IdempotencyKey = request.IdempotencyKey, + ExpiresAt = request.ExpiresAt, + Status = Domain.Inventory.Enums.InventoryLockStatus.Locked + }; + + await inventoryRepository.AddLockAsync(lockRecord, cancellationToken); + await inventoryRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("锁定库存 门店 {StoreId} SKU {ProductSkuId} 数量 {Quantity}", request.StoreId, request.ProductSkuId, request.Quantity); + return InventoryMapping.ToDto(item); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseExpiredInventoryLocksCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseExpiredInventoryLocksCommandHandler.cs new file mode 100644 index 0000000..0ef2884 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseExpiredInventoryLocksCommandHandler.cs @@ -0,0 +1,60 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Domain.Inventory.Enums; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 释放过期锁定处理器。 +/// +public sealed class ReleaseExpiredInventoryLocksCommandHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(ReleaseExpiredInventoryLocksCommand request, CancellationToken cancellationToken) + { + // 1. 查询过期锁 + var tenantId = tenantProvider.GetCurrentTenantId(); + var now = DateTime.UtcNow; + var expiredLocks = await inventoryRepository.FindExpiredLocksAsync(tenantId, now, cancellationToken); + if (expiredLocks.Count == 0) + { + return 0; + } + + // 2. 释放锁对应库存 + var affected = 0; + foreach (var lockRecord in expiredLocks) + { + var item = await inventoryRepository.GetForUpdateAsync(tenantId, lockRecord.StoreId, lockRecord.ProductSkuId, cancellationToken); + if (item is null) + { + continue; + } + + if (lockRecord.IsPresale) + { + item.PresaleLocked = Math.Max(0, item.PresaleLocked - lockRecord.Quantity); + } + else + { + item.QuantityReserved = Math.Max(0, item.QuantityReserved - lockRecord.Quantity); + } + + item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0); + await inventoryRepository.UpdateItemAsync(item, cancellationToken); + await inventoryRepository.MarkLockStatusAsync(lockRecord, InventoryLockStatus.Released, cancellationToken); + affected++; + } + + await inventoryRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("释放过期库存锁定 {Count} 条", affected); + return affected; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseInventoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseInventoryCommandHandler.cs new file mode 100644 index 0000000..b159471 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/ReleaseInventoryCommandHandler.cs @@ -0,0 +1,76 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 库存释放处理器。 +/// +public sealed class ReleaseInventoryCommandHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(ReleaseInventoryCommand request, CancellationToken cancellationToken) + { + // 1. 读取库存 + var tenantId = tenantProvider.GetCurrentTenantId(); + var item = await inventoryRepository.GetForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, cancellationToken); + if (item is null) + { + throw new BusinessException(ErrorCodes.NotFound, "库存不存在"); + } + + // 1.1 幂等处理:若提供键且锁记录不存在,直接视为已释放 + if (!string.IsNullOrWhiteSpace(request.IdempotencyKey)) + { + var lockRecord = await inventoryRepository.FindLockByKeyAsync(tenantId, request.IdempotencyKey, cancellationToken); + if (lockRecord is not null) + { + if (lockRecord.Status != Domain.Inventory.Enums.InventoryLockStatus.Locked) + { + return InventoryMapping.ToDto(item); + } + + // 将数量同步为锁记录数,避免重复释放不一致 + request = request with { Quantity = lockRecord.Quantity }; + await inventoryRepository.MarkLockStatusAsync(lockRecord, Domain.Inventory.Enums.InventoryLockStatus.Released, cancellationToken); + } + } + + // 2. 计算释放 + var isPresale = request.IsPresaleOrder || item.IsPresale; + if (isPresale) + { + if (item.PresaleLocked < request.Quantity) + { + throw new BusinessException(ErrorCodes.Conflict, "预售锁定不足"); + } + + item.PresaleLocked -= request.Quantity; + } + else + { + if (item.QuantityReserved < request.Quantity) + { + throw new BusinessException(ErrorCodes.Conflict, "锁定库存不足"); + } + + item.QuantityReserved -= request.Quantity; + } + + item.IsSoldOut = item.QuantityOnHand - item.QuantityReserved - item.PresaleLocked <= (item.SafetyStock ?? 0); + await inventoryRepository.UpdateItemAsync(item, cancellationToken); + await inventoryRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("释放库存 门店 {StoreId} SKU {ProductSkuId} 数量 {Quantity}", request.StoreId, request.ProductSkuId, request.Quantity); + return InventoryMapping.ToDto(item); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/UpsertInventoryBatchCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/UpsertInventoryBatchCommandHandler.cs new file mode 100644 index 0000000..dea621d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Handlers/UpsertInventoryBatchCommandHandler.cs @@ -0,0 +1,57 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Inventory.Commands; +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Entities; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Inventory.Handlers; + +/// +/// 批次维护处理器。 +/// +public sealed class UpsertInventoryBatchCommandHandler( + IInventoryRepository inventoryRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpsertInventoryBatchCommand request, CancellationToken cancellationToken) + { + // 1. 读取批次 + var tenantId = tenantProvider.GetCurrentTenantId(); + var batch = await inventoryRepository.GetBatchForUpdateAsync(tenantId, request.StoreId, request.ProductSkuId, request.BatchNumber, cancellationToken); + // 2. 创建或更新 + if (batch is null) + { + batch = new InventoryBatch + { + TenantId = tenantId, + StoreId = request.StoreId, + ProductSkuId = request.ProductSkuId, + BatchNumber = request.BatchNumber, + ProductionDate = request.ProductionDate, + ExpireDate = request.ExpireDate, + Quantity = request.Quantity, + RemainingQuantity = request.RemainingQuantity + }; + await inventoryRepository.AddBatchAsync(batch, cancellationToken); + } + else + { + batch.ProductionDate = request.ProductionDate; + batch.ExpireDate = request.ExpireDate; + batch.Quantity = request.Quantity; + batch.RemainingQuantity = request.RemainingQuantity; + await inventoryRepository.UpdateBatchAsync(batch, cancellationToken); + } + + await inventoryRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("维护批次 门店 {StoreId} SKU {ProductSkuId} 批次 {BatchNumber}", request.StoreId, request.ProductSkuId, request.BatchNumber); + return InventoryMapping.ToDto(batch); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/InventoryMapping.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/InventoryMapping.cs new file mode 100644 index 0000000..b4c9cbd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/InventoryMapping.cs @@ -0,0 +1,49 @@ +using TakeoutSaaS.Application.App.Inventory.Dto; +using TakeoutSaaS.Domain.Inventory.Entities; + +namespace TakeoutSaaS.Application.App.Inventory; + +/// +/// 库存映射辅助。 +/// +public static class InventoryMapping +{ + /// + /// 映射库存 DTO。 + /// + public static InventoryItemDto ToDto(InventoryItem item) => new() + { + Id = item.Id, + TenantId = item.TenantId, + StoreId = item.StoreId, + ProductSkuId = item.ProductSkuId, + BatchNumber = item.BatchNumber, + QuantityOnHand = item.QuantityOnHand, + QuantityReserved = item.QuantityReserved, + SafetyStock = item.SafetyStock, + Location = item.Location, + ExpireDate = item.ExpireDate, + IsPresale = item.IsPresale, + PresaleStartTime = item.PresaleStartTime, + PresaleEndTime = item.PresaleEndTime, + PresaleCapacity = item.PresaleCapacity, + PresaleLocked = item.PresaleLocked, + MaxQuantityPerOrder = item.MaxQuantityPerOrder, + IsSoldOut = item.IsSoldOut + }; + + /// + /// 映射批次 DTO。 + /// + public static InventoryBatchDto ToDto(InventoryBatch batch) => new() + { + Id = batch.Id, + StoreId = batch.StoreId, + ProductSkuId = batch.ProductSkuId, + BatchNumber = batch.BatchNumber, + ProductionDate = batch.ProductionDate, + ExpireDate = batch.ExpireDate, + Quantity = batch.Quantity, + RemainingQuantity = batch.RemainingQuantity + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryBatchesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryBatchesQuery.cs new file mode 100644 index 0000000..c95f4cc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryBatchesQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; + +namespace TakeoutSaaS.Application.App.Inventory.Queries; + +/// +/// 查询库存批次列表。 +/// +public sealed record GetInventoryBatchesQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryItemQuery.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryItemQuery.cs new file mode 100644 index 0000000..446b59a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Queries/GetInventoryItemQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Inventory.Dto; + +namespace TakeoutSaaS.Application.App.Inventory.Queries; + +/// +/// 按门店与 SKU 查询库存。 +/// +public sealed record GetInventoryItemQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/AdjustInventoryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/AdjustInventoryCommandValidator.cs new file mode 100644 index 0000000..e74ea14 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/AdjustInventoryCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Inventory.Commands; + +namespace TakeoutSaaS.Application.App.Inventory.Validators; + +/// +/// 库存调整命令验证器。 +/// +public sealed class AdjustInventoryCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public AdjustInventoryCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ProductSkuId).GreaterThan(0); + RuleFor(x => x.QuantityDelta).NotEqual(0); + RuleFor(x => x.SafetyStock).GreaterThanOrEqualTo(0).When(x => x.SafetyStock.HasValue); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/DeductInventoryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/DeductInventoryCommandValidator.cs new file mode 100644 index 0000000..53eba84 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/DeductInventoryCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Inventory.Commands; + +namespace TakeoutSaaS.Application.App.Inventory.Validators; + +/// +/// 扣减库存命令验证器。 +/// +public sealed class DeductInventoryCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public DeductInventoryCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ProductSkuId).GreaterThan(0); + RuleFor(x => x.Quantity).GreaterThan(0); + RuleFor(x => x.IdempotencyKey).MaximumLength(128); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/LockInventoryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/LockInventoryCommandValidator.cs new file mode 100644 index 0000000..38aa7ec --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/LockInventoryCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Inventory.Commands; + +namespace TakeoutSaaS.Application.App.Inventory.Validators; + +/// +/// 库存锁定命令验证器。 +/// +public sealed class LockInventoryCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public LockInventoryCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ProductSkuId).GreaterThan(0); + RuleFor(x => x.Quantity).GreaterThan(0); + RuleFor(x => x.IdempotencyKey).NotEmpty().MaximumLength(128); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/ReleaseInventoryCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/ReleaseInventoryCommandValidator.cs new file mode 100644 index 0000000..ed0b8dc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/ReleaseInventoryCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Inventory.Commands; + +namespace TakeoutSaaS.Application.App.Inventory.Validators; + +/// +/// 释放库存命令验证器。 +/// +public sealed class ReleaseInventoryCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReleaseInventoryCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ProductSkuId).GreaterThan(0); + RuleFor(x => x.Quantity).GreaterThan(0); + RuleFor(x => x.IdempotencyKey).MaximumLength(128); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/UpsertInventoryBatchCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/UpsertInventoryBatchCommandValidator.cs new file mode 100644 index 0000000..abcb278 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Inventory/Validators/UpsertInventoryBatchCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Inventory.Commands; + +namespace TakeoutSaaS.Application.App.Inventory.Validators; + +/// +/// 批次维护命令验证器。 +/// +public sealed class UpsertInventoryBatchCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpsertInventoryBatchCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ProductSkuId).GreaterThan(0); + RuleFor(x => x.BatchNumber).NotEmpty().MaximumLength(64); + RuleFor(x => x.Quantity).GreaterThan(0); + RuleFor(x => x.RemainingQuantity).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs new file mode 100644 index 0000000..bc40f63 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/AddMerchantDocumentCommand.cs @@ -0,0 +1,17 @@ +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 record AddMerchantDocumentCommand( + [property: Required] long MerchantId, + [property: Required] MerchantDocumentType DocumentType, + [property: Required, MaxLength(512)] string FileUrl, + [property: MaxLength(64)] string? DocumentNumber, + DateTime? IssuedAt, + DateTime? ExpiresAt) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs new file mode 100644 index 0000000..fe01142 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantCategoryCommand.cs @@ -0,0 +1,13 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 新增商户类目。 +/// +public sealed record CreateMerchantCategoryCommand( + [property: Required, MaxLength(64)] string Name, + int? DisplayOrder, + bool IsActive = true) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs new file mode 100644 index 0000000..957ba01 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/CreateMerchantContractCommand.cs @@ -0,0 +1,15 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 新建商户合同。 +/// +public sealed record CreateMerchantContractCommand( + [property: Required] long MerchantId, + [property: Required, MaxLength(64)] string ContractNumber, + DateTime StartDate, + DateTime EndDate, + [property: Required, MaxLength(512)] string FileUrl) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs new file mode 100644 index 0000000..98cfd21 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/DeleteMerchantCategoryCommand.cs @@ -0,0 +1,9 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 删除商户类目。 +/// +public sealed record DeleteMerchantCategoryCommand([property: Required] long CategoryId) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs new file mode 100644 index 0000000..542e6aa --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReorderMerchantCategoriesCommand.cs @@ -0,0 +1,17 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 调整类目排序。 +/// +public sealed record ReorderMerchantCategoriesCommand( + [property: Required, MinLength(1)] IReadOnlyList Items) : IRequest; + +/// +/// 类目排序条目。 +/// +public sealed record MerchantCategoryOrderItem( + [property: Required] long CategoryId, + [property: Range(-1000, 100000)] int DisplayOrder); diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs new file mode 100644 index 0000000..362e20b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantCommand.cs @@ -0,0 +1,13 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 审核商户入驻。 +/// +public sealed record ReviewMerchantCommand( + [property: Required] long MerchantId, + bool Approve, + string? Remarks) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs new file mode 100644 index 0000000..0e01c26 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/ReviewMerchantDocumentCommand.cs @@ -0,0 +1,14 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Commands; + +/// +/// 审核商户证照。 +/// +public sealed record ReviewMerchantDocumentCommand( + [property: Required] long MerchantId, + [property: Required] long DocumentId, + bool Approve, + string? Remarks) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs new file mode 100644 index 0000000..f89f9bf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Commands/UpdateMerchantContractStatusCommand.cs @@ -0,0 +1,16 @@ +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 record UpdateMerchantContractStatusCommand( + [property: Required] long MerchantId, + [property: Required] long ContractId, + [property: Required] ContractStatus Status, + DateTime? SignedAt, + string? Reason) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantAuditLogDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantAuditLogDto.cs new file mode 100644 index 0000000..2c42c35 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantAuditLogDto.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Merchants.Dto; + +/// +/// 商户审核日志 DTO。 +/// +public sealed class MerchantAuditLogDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long MerchantId { get; init; } + + public MerchantAuditAction Action { get; init; } + + public string Title { get; init; } = string.Empty; + + public string? Description { get; init; } + + public string? OperatorName { get; init; } + + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs new file mode 100644 index 0000000..c8741ca --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantCategoryDto.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.App.Merchants.Dto; + +/// +/// 商户类目 DTO。 +/// +public sealed record MerchantCategoryDto +{ + /// + /// 类目标识。 + /// + public long Id { get; init; } + + /// + /// 类目名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 显示顺序。 + /// + public int DisplayOrder { get; init; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantContractDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantContractDto.cs new file mode 100644 index 0000000..3c12bb3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantContractDto.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Merchants.Dto; + +/// +/// 商户合同 DTO。 +/// +public sealed class MerchantContractDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long MerchantId { get; init; } + + public string ContractNumber { get; init; } = string.Empty; + + public ContractStatus Status { get; init; } + + public DateTime StartDate { get; init; } + + public DateTime EndDate { get; init; } + + public string FileUrl { get; init; } = string.Empty; + + public DateTime? SignedAt { get; init; } + + public DateTime? TerminatedAt { get; init; } + + public string? TerminationReason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs new file mode 100644 index 0000000..2fa8b95 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDetailDto.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.App.Merchants.Dto; + +/// +/// 商户详情 DTO。 +/// +public sealed class MerchantDetailDto +{ + /// + /// 基础信息。 + /// + public MerchantDto Merchant { get; init; } = new(); + + /// + /// 证照列表。 + /// + public IReadOnlyList Documents { get; init; } = []; + + /// + /// 合同列表。 + /// + public IReadOnlyList Contracts { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDocumentDto.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDocumentDto.cs new file mode 100644 index 0000000..8723031 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Dto/MerchantDocumentDto.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Merchants.Dto; + +/// +/// 商户证照 DTO。 +/// +public sealed class MerchantDocumentDto +{ + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long MerchantId { get; init; } + + public MerchantDocumentType DocumentType { get; init; } + + public MerchantDocumentStatus Status { get; init; } + + public string FileUrl { get; init; } = string.Empty; + + public string? DocumentNumber { get; init; } + + public DateTime? IssuedAt { get; init; } + + public DateTime? ExpiresAt { get; init; } + + public string? Remarks { get; init; } + + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs new file mode 100644 index 0000000..2acfd29 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/AddMerchantDocumentCommandHandler.cs @@ -0,0 +1,75 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 处理证照上传。 +/// +public sealed class AddMerchantDocumentCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + IIdGenerator idGenerator, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + public async Task Handle(AddMerchantDocumentCommand request, CancellationToken cancellationToken) + { + // 1. 获取租户并查询商户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + + // 2. 构建证照记录 + var document = new MerchantDocument + { + Id = idGenerator.NextId(), + MerchantId = merchant.Id, + DocumentType = request.DocumentType, + Status = MerchantDocumentStatus.Pending, + FileUrl = request.FileUrl.Trim(), + DocumentNumber = request.DocumentNumber?.Trim(), + IssuedAt = request.IssuedAt, + ExpiresAt = request.ExpiresAt + }; + + // 3. 持久化与审计 + await merchantRepository.AddDocumentAsync(document, cancellationToken); + await merchantRepository.AddAuditLogAsync(new MerchantAuditLog + { + TenantId = tenantId, + MerchantId = merchant.Id, + Action = MerchantAuditAction.DocumentUploaded, + Title = "上传证照", + Description = $"类型:{request.DocumentType}", + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName() + }, cancellationToken); + + await merchantRepository.SaveChangesAsync(cancellationToken); + + // 4. 返回 DTO + return MerchantMapping.ToDto(document); + } + + private long? ResolveOperatorId() + { + var id = currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs new file mode 100644 index 0000000..645189c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCategoryCommandHandler.cs @@ -0,0 +1,50 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 创建类目处理器。 +/// +public sealed class CreateMerchantCategoryCommandHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + public async Task Handle(CreateMerchantCategoryCommand request, CancellationToken cancellationToken) + { + // 1. 获取租户上下文 + var tenantId = tenantProvider.GetCurrentTenantId(); + var normalizedName = request.Name.Trim(); + + // 2. 检查重名 + if (await categoryRepository.ExistsAsync(normalizedName, tenantId, cancellationToken)) + { + throw new BusinessException(ErrorCodes.Conflict, $"类目“{normalizedName}”已存在"); + } + + // 3. 计算排序 + var categories = await categoryRepository.ListAsync(tenantId, cancellationToken); + var targetOrder = request.DisplayOrder ?? (categories.Count == 0 ? 1 : categories.Max(x => x.DisplayOrder) + 1); + + // 4. 构建实体 + var entity = new MerchantCategory + { + Name = normalizedName, + DisplayOrder = targetOrder, + IsActive = request.IsActive + }; + + // 5. 持久化并返回 + await categoryRepository.AddAsync(entity, cancellationToken); + await categoryRepository.SaveChangesAsync(cancellationToken); + + return MerchantMapping.ToDto(entity); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs index 70c0982..54a5bba 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantCommandHandler.cs @@ -13,12 +13,10 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers; public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRepository, ILogger logger) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ILogger _logger = logger; - /// public async Task Handle(CreateMerchantCommand request, CancellationToken cancellationToken) { + // 1. 构建商户实体 var merchant = new Merchant { BrandName = request.BrandName.Trim(), @@ -31,10 +29,12 @@ public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRep JoinedAt = DateTime.UtcNow }; - await _merchantRepository.AddMerchantAsync(merchant, cancellationToken); - await _merchantRepository.SaveChangesAsync(cancellationToken); + // 2. 持久化 + await merchantRepository.AddMerchantAsync(merchant, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("创建商户 {MerchantId} - {BrandName}", merchant.Id, merchant.BrandName); + // 3. 记录日志 + logger.LogInformation("创建商户 {MerchantId} - {BrandName}", merchant.Id, merchant.BrandName); return MapToDto(merchant); } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs new file mode 100644 index 0000000..ff809dc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/CreateMerchantContractCommandHandler.cs @@ -0,0 +1,79 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 创建商户合同。 +/// +public sealed class CreateMerchantContractCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + IIdGenerator idGenerator, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + public async Task Handle(CreateMerchantContractCommand request, CancellationToken cancellationToken) + { + // 1. 校验时间 + if (request.EndDate <= request.StartDate) + { + throw new BusinessException(ErrorCodes.BadRequest, "合同结束时间必须晚于开始时间"); + } + + // 2. 查询商户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + + // 3. 构建合同 + var contract = new MerchantContract + { + Id = idGenerator.NextId(), + MerchantId = merchant.Id, + ContractNumber = request.ContractNumber.Trim(), + StartDate = request.StartDate, + EndDate = request.EndDate, + FileUrl = request.FileUrl.Trim() + }; + + // 4. 持久化与审计 + await merchantRepository.AddContractAsync(contract, cancellationToken); + await merchantRepository.AddAuditLogAsync(new MerchantAuditLog + { + TenantId = tenantId, + MerchantId = merchant.Id, + Action = MerchantAuditAction.ContractUpdated, + Title = "新增合同", + Description = $"合同号:{contract.ContractNumber}", + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName() + }, cancellationToken); + + await merchantRepository.SaveChangesAsync(cancellationToken); + + // 5. 返回 DTO + return MerchantMapping.ToDto(contract); + } + + private long? ResolveOperatorId() + { + var id = currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs new file mode 100644 index 0000000..e4cf48e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCategoryCommandHandler.cs @@ -0,0 +1,32 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 删除类目处理器。 +/// +public sealed class DeleteMerchantCategoryCommandHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + public async Task Handle(DeleteMerchantCategoryCommand request, CancellationToken cancellationToken) + { + // 1. 获取租户上下文 + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await categoryRepository.FindByIdAsync(request.CategoryId, tenantId, cancellationToken); + + if (existing == null) + { + return false; + } + + // 2. 删除并保存 + await categoryRepository.RemoveAsync(existing, cancellationToken); + await categoryRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCommandHandler.cs index 8f69ff0..c79f74f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/DeleteMerchantCommandHandler.cs @@ -15,25 +15,21 @@ public sealed class DeleteMerchantCommandHandler( ILogger logger) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(DeleteMerchantCommand request, CancellationToken cancellationToken) { // 1. 校验存在性 - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); if (existing == null) { return false; } // 2. 删除 - await _merchantRepository.DeleteMerchantAsync(request.MerchantId, tenantId, cancellationToken); - await _merchantRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("删除商户 {MerchantId}", request.MerchantId); + await merchantRepository.DeleteMerchantAsync(request.MerchantId, tenantId, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除商户 {MerchantId}", request.MerchantId); return true; } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs new file mode 100644 index 0000000..2fd67e5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantAuditLogsQueryHandler.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 读取商户审核日志。 +/// +public sealed class GetMerchantAuditLogsQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + public async Task> Handle(GetMerchantAuditLogsQuery request, CancellationToken cancellationToken) + { + // 1. 获取租户上下文并查询日志 + var tenantId = tenantProvider.GetCurrentTenantId(); + var logs = await merchantRepository.GetAuditLogsAsync(request.MerchantId, tenantId, cancellationToken); + var total = logs.Count; + + // 2. 分页映射 + var paged = logs + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .Select(MerchantMapping.ToDto) + .ToList(); + + // 3. 返回结果 + return new PagedResult(paged, request.Page, request.PageSize, total); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantByIdQueryHandler.cs index 3c2313f..8272c6c 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantByIdQueryHandler.cs @@ -12,19 +12,18 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers; public sealed class GetMerchantByIdQueryHandler(IMerchantRepository merchantRepository, ITenantProvider tenantProvider) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task Handle(GetMerchantByIdQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); + // 1. 获取租户上下文 + var tenantId = tenantProvider.GetCurrentTenantId(); + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); if (merchant == null) { return null; } + // 2. 返回 DTO return new MerchantDto { Id = merchant.Id, diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs new file mode 100644 index 0000000..4ffc992 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantCategoriesQueryHandler.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 读取可选类目。 +/// +public sealed class GetMerchantCategoriesQueryHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + public async Task> Handle(GetMerchantCategoriesQuery request, CancellationToken cancellationToken) + { + // 1. 获取租户上下文并读取类目 + var tenantId = tenantProvider.GetCurrentTenantId(); + var categories = await categoryRepository.ListAsync(tenantId, cancellationToken); + + // 2. 过滤启用类目并去重 + return categories + .Where(x => x.IsActive) + .Select(x => x.Name.Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() + .AsReadOnly(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs new file mode 100644 index 0000000..8ce236b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantContractsQueryHandler.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 查询合同列表。 +/// +public sealed class GetMerchantContractsQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + public async Task> Handle(GetMerchantContractsQuery request, CancellationToken cancellationToken) + { + // 1. 获取租户上下文并校验商户存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + _ = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + + // 2. 查询合同列表 + var contracts = await merchantRepository.GetContractsAsync(request.MerchantId, tenantId, cancellationToken); + return MerchantMapping.ToContractDtos(contracts); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs new file mode 100644 index 0000000..0468a29 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDetailQueryHandler.cs @@ -0,0 +1,38 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 商户详情处理器。 +/// +public sealed class GetMerchantDetailQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + public async Task Handle(GetMerchantDetailQuery request, CancellationToken cancellationToken) + { + // 1. 获取租户上下文并查询商户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + + // 2. 查询证照与合同 + var documents = await merchantRepository.GetDocumentsAsync(request.MerchantId, tenantId, cancellationToken); + var contracts = await merchantRepository.GetContractsAsync(request.MerchantId, tenantId, cancellationToken); + + // 3. 返回明细 DTO + return new MerchantDetailDto + { + Merchant = MerchantMapping.ToDto(merchant), + Documents = MerchantMapping.ToDocumentDtos(documents), + Contracts = MerchantMapping.ToContractDtos(contracts) + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs new file mode 100644 index 0000000..c93f19c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/GetMerchantDocumentsQueryHandler.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 查询证照列表。 +/// +public sealed class GetMerchantDocumentsQueryHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + public async Task> Handle(GetMerchantDocumentsQuery request, CancellationToken cancellationToken) + { + // 1. 获取租户上下文并校验商户存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + _ = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + + // 2. 查询证照列表 + var documents = await merchantRepository.GetDocumentsAsync(request.MerchantId, tenantId, cancellationToken); + return MerchantMapping.ToDocumentDtos(documents); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs new file mode 100644 index 0000000..c0ba13b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ListMerchantCategoriesQueryHandler.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Application.App.Merchants.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 列出类目。 +/// +public sealed class ListMerchantCategoriesQueryHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + public async Task> Handle(ListMerchantCategoriesQuery request, CancellationToken cancellationToken) + { + // 1. 获取租户上下文 + var tenantId = tenantProvider.GetCurrentTenantId(); + var categories = await categoryRepository.ListAsync(tenantId, cancellationToken); + + // 2. 映射 DTO + return MerchantMapping.ToCategoryDtos(categories); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs new file mode 100644 index 0000000..cb71205 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReorderMerchantCategoriesCommandHandler.cs @@ -0,0 +1,41 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 类目排序处理器。 +/// +public sealed class ReorderMerchantCategoriesCommandHandler( + IMerchantCategoryRepository categoryRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + public async Task Handle(ReorderMerchantCategoriesCommand request, CancellationToken cancellationToken) + { + // 1. 获取租户并查询类目 + var tenantId = tenantProvider.GetCurrentTenantId(); + var categories = await categoryRepository.ListAsync(tenantId, cancellationToken); + var map = categories.ToDictionary(x => x.Id); + + // 2. 更新排序 + foreach (var item in request.Items) + { + if (!map.TryGetValue(item.CategoryId, out var category)) + { + throw new BusinessException(ErrorCodes.NotFound, $"类目 {item.CategoryId} 不存在"); + } + + category.DisplayOrder = item.DisplayOrder; + } + + // 3. 持久化 + await categoryRepository.UpdateRangeAsync(map.Values, cancellationToken); + await categoryRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs new file mode 100644 index 0000000..c684c09 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantCommandHandler.cs @@ -0,0 +1,75 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 商户审核处理器。 +/// +public sealed class ReviewMerchantCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + public async Task Handle(ReviewMerchantCommand request, CancellationToken cancellationToken) + { + // 1. 读取商户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var merchant = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "商户不存在"); + + // 2. 已审核通过则直接返回 + if (request.Approve && merchant.Status == MerchantStatus.Approved) + { + return MerchantMapping.ToDto(merchant); + } + + // 3. 更新审核状态 + var previousStatus = merchant.Status; + merchant.Status = request.Approve ? MerchantStatus.Approved : MerchantStatus.Rejected; + merchant.ReviewRemarks = request.Remarks; + merchant.LastReviewedAt = DateTime.UtcNow; + if (request.Approve && merchant.JoinedAt == null) + { + merchant.JoinedAt = DateTime.UtcNow; + } + + // 4. 持久化与审计 + await merchantRepository.UpdateMerchantAsync(merchant, cancellationToken); + await merchantRepository.AddAuditLogAsync(new MerchantAuditLog + { + TenantId = tenantId, + MerchantId = merchant.Id, + Action = MerchantAuditAction.MerchantReviewed, + Title = request.Approve ? "商户审核通过" : "商户审核驳回", + Description = request.Remarks, + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName() + }, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + + // 5. 返回 DTO + return MerchantMapping.ToDto(merchant); + } + + private long? ResolveOperatorId() + { + var id = currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs new file mode 100644 index 0000000..7260afb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/ReviewMerchantDocumentCommandHandler.cs @@ -0,0 +1,70 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 审核证照处理器。 +/// +public sealed class ReviewMerchantDocumentCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + public async Task Handle(ReviewMerchantDocumentCommand request, CancellationToken cancellationToken) + { + // 1. 读取证照 + var tenantId = tenantProvider.GetCurrentTenantId(); + var document = await merchantRepository.FindDocumentByIdAsync(request.MerchantId, tenantId, request.DocumentId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "证照不存在"); + + // 2. 若状态无变化且备注相同,直接返回 + var targetStatus = request.Approve ? MerchantDocumentStatus.Approved : MerchantDocumentStatus.Rejected; + if (document.Status == targetStatus && document.Remarks == request.Remarks) + { + return MerchantMapping.ToDto(document); + } + + // 3. 更新状态 + document.Status = targetStatus; + document.Remarks = request.Remarks; + + // 4. 持久化与审计 + await merchantRepository.UpdateDocumentAsync(document, cancellationToken); + await merchantRepository.AddAuditLogAsync(new MerchantAuditLog + { + TenantId = tenantId, + MerchantId = document.MerchantId, + Action = MerchantAuditAction.DocumentReviewed, + Title = request.Approve ? "证照审核通过" : "证照审核驳回", + Description = request.Remarks, + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName() + }, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + + // 5. 返回 DTO + return MerchantMapping.ToDto(document); + } + + private long? ResolveOperatorId() + { + var id = currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/SearchMerchantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/SearchMerchantsQueryHandler.cs index 8d46242..b545129 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/SearchMerchantsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/SearchMerchantsQueryHandler.cs @@ -15,21 +15,21 @@ public sealed class SearchMerchantsQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task> Handle(SearchMerchantsQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var merchants = await _merchantRepository.SearchAsync(tenantId, request.Status, cancellationToken); + // 1. 获取租户并查询商户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var merchants = await merchantRepository.SearchAsync(tenantId, request.Status, cancellationToken); + // 2. 排序与分页 var sorted = ApplySorting(merchants, request.SortBy, request.SortDescending); var paged = sorted .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 3. 映射 DTO var items = paged.Select(merchant => new MerchantDto { Id = merchant.Id, @@ -45,6 +45,7 @@ public sealed class SearchMerchantsQueryHandler( CreatedAt = merchant.CreatedAt }).ToList(); + // 4. 返回分页结果 return new PagedResult(items, request.Page, request.PageSize, merchants.Count); } diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs index 38753c9..ed1a879 100644 --- a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantCommandHandler.cs @@ -16,16 +16,12 @@ public sealed class UpdateMerchantCommandHandler( ILogger logger) : IRequestHandler { - private readonly IMerchantRepository _merchantRepository = merchantRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(UpdateMerchantCommand request, CancellationToken cancellationToken) { // 1. 读取现有商户 - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken); if (existing == null) { return null; @@ -41,9 +37,9 @@ public sealed class UpdateMerchantCommandHandler( existing.Status = request.Status; // 3. 持久化 - await _merchantRepository.UpdateMerchantAsync(existing, cancellationToken); - await _merchantRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("更新商户 {MerchantId} - {BrandName}", existing.Id, existing.BrandName); + await merchantRepository.UpdateMerchantAsync(existing, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新商户 {MerchantId} - {BrandName}", existing.Id, existing.BrandName); // 4. 返回 DTO return MapToDto(existing); diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs new file mode 100644 index 0000000..2d2ba95 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Handlers/UpdateMerchantContractStatusCommandHandler.cs @@ -0,0 +1,77 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Commands; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Merchants.Handlers; + +/// +/// 更新合同状态处理器。 +/// +public sealed class UpdateMerchantContractStatusCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + public async Task Handle(UpdateMerchantContractStatusCommand request, CancellationToken cancellationToken) + { + // 1. 查询合同 + var tenantId = tenantProvider.GetCurrentTenantId(); + var contract = await merchantRepository.FindContractByIdAsync(request.MerchantId, tenantId, request.ContractId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "合同不存在"); + + // 2. 更新状态 + if (request.Status == ContractStatus.Active) + { + contract.Status = ContractStatus.Active; + contract.SignedAt = request.SignedAt ?? DateTime.UtcNow; + } + else if (request.Status == ContractStatus.Terminated) + { + contract.Status = ContractStatus.Terminated; + contract.TerminatedAt = DateTime.UtcNow; + contract.TerminationReason = request.Reason; + } + else + { + contract.Status = request.Status; + } + + // 3. 持久化与审计 + await merchantRepository.UpdateContractAsync(contract, cancellationToken); + await merchantRepository.AddAuditLogAsync(new MerchantAuditLog + { + TenantId = tenantId, + MerchantId = contract.MerchantId, + Action = MerchantAuditAction.ContractStatusChanged, + Title = $"合同状态变更为 {request.Status}", + Description = request.Reason, + OperatorId = ResolveOperatorId(), + OperatorName = ResolveOperatorName() + }, cancellationToken); + + await merchantRepository.SaveChangesAsync(cancellationToken); + + // 4. 返回 DTO + return MerchantMapping.ToDto(contract); + } + + private long? ResolveOperatorId() + { + var id = currentUserAccessor.UserId; + return id == 0 ? null : id; + } + + private string ResolveOperatorName() + { + var id = currentUserAccessor.UserId; + return id == 0 ? "system" : $"user:{id}"; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs new file mode 100644 index 0000000..4b52d54 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/MerchantMapping.cs @@ -0,0 +1,82 @@ +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; + +namespace TakeoutSaaS.Application.App.Merchants; + +/// +/// 商户 DTO 映射工具。 +/// +internal static class MerchantMapping +{ + public static MerchantDto ToDto(Merchant merchant) => new() + { + Id = merchant.Id, + TenantId = merchant.TenantId, + BrandName = merchant.BrandName, + BrandAlias = merchant.BrandAlias, + LogoUrl = merchant.LogoUrl, + Category = merchant.Category, + ContactPhone = merchant.ContactPhone, + ContactEmail = merchant.ContactEmail, + Status = merchant.Status, + JoinedAt = merchant.JoinedAt, + CreatedAt = merchant.CreatedAt + }; + + public static MerchantDocumentDto ToDto(MerchantDocument document) => new() + { + Id = document.Id, + MerchantId = document.MerchantId, + DocumentType = document.DocumentType, + Status = document.Status, + FileUrl = document.FileUrl, + DocumentNumber = document.DocumentNumber, + IssuedAt = document.IssuedAt, + ExpiresAt = document.ExpiresAt, + Remarks = document.Remarks, + CreatedAt = document.CreatedAt + }; + + public static MerchantContractDto ToDto(MerchantContract contract) => new() + { + Id = contract.Id, + MerchantId = contract.MerchantId, + ContractNumber = contract.ContractNumber, + Status = contract.Status, + StartDate = contract.StartDate, + EndDate = contract.EndDate, + FileUrl = contract.FileUrl, + SignedAt = contract.SignedAt, + TerminatedAt = contract.TerminatedAt, + TerminationReason = contract.TerminationReason + }; + + public static MerchantAuditLogDto ToDto(MerchantAuditLog log) => new() + { + Id = log.Id, + MerchantId = log.MerchantId, + Action = log.Action, + Title = log.Title, + Description = log.Description, + OperatorName = log.OperatorName, + CreatedAt = log.CreatedAt + }; + + public static MerchantCategoryDto ToDto(MerchantCategory category) => new() + { + Id = category.Id, + Name = category.Name, + DisplayOrder = category.DisplayOrder, + IsActive = category.IsActive, + CreatedAt = category.CreatedAt + }; + + public static IReadOnlyList ToDocumentDtos(IEnumerable documents) + => documents.Select(ToDto).ToList(); + + public static IReadOnlyList ToContractDtos(IEnumerable contracts) + => contracts.Select(ToDto).ToList(); + + public static IReadOnlyList ToCategoryDtos(IEnumerable categories) + => categories.Select(ToDto).ToList(); +} diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantAuditLogsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantAuditLogsQuery.cs new file mode 100644 index 0000000..e82a58c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantAuditLogsQuery.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 商户审核日志查询。 +/// +public sealed record GetMerchantAuditLogsQuery( + long MerchantId, + int Page = 1, + int PageSize = 20) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs new file mode 100644 index 0000000..558bcc6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantCategoriesQuery.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 获取商户可选类目。 +/// +public sealed record GetMerchantCategoriesQuery() : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs new file mode 100644 index 0000000..bdf17d6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantContractsQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 查询商户合同。 +/// +public sealed record GetMerchantContractsQuery(long MerchantId) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDetailQuery.cs new file mode 100644 index 0000000..f3b3eaa --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDetailQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 商户详情查询。 +/// +public sealed record GetMerchantDetailQuery(long MerchantId) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs new file mode 100644 index 0000000..f3dd1bf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/GetMerchantDocumentsQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 查询商户证照。 +/// +public sealed record GetMerchantDocumentsQuery(long MerchantId) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs new file mode 100644 index 0000000..5fa29e9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Merchants/Queries/ListMerchantCategoriesQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.Merchants.Dto; + +namespace TakeoutSaaS.Application.App.Merchants.Queries; + +/// +/// 管理端获取完整类目列表。 +/// +public sealed record ListMerchantCategoriesQuery() : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs index a7b1289..9b3cc0f 100644 --- a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/CreateOrderCommandHandler.cs @@ -17,17 +17,13 @@ public sealed class CreateOrderCommandHandler( ILogger logger) : IRequestHandler { - private readonly IOrderRepository _orderRepository = orderRepository; - private readonly IIdGenerator _idGenerator = idGenerator; - private readonly ILogger _logger = logger; - /// public async Task Handle(CreateOrderCommand request, CancellationToken cancellationToken) { // 1. 构建订单 var order = new Order { - Id = _idGenerator.NextId(), + Id = idGenerator.NextId(), OrderNo = request.OrderNo.Trim(), StoreId = request.StoreId, Channel = request.Channel, @@ -77,15 +73,18 @@ public sealed class CreateOrderCommandHandler( } // 4. 持久化 - await _orderRepository.AddOrderAsync(order, cancellationToken); + await orderRepository.AddOrderAsync(order, cancellationToken); if (items.Count > 0) { - await _orderRepository.AddItemsAsync(items, cancellationToken); + await orderRepository.AddItemsAsync(items, cancellationToken); } - await _orderRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("创建订单 {OrderNo} ({OrderId})", order.OrderNo, order.Id); - // 5. 返回 DTO + await orderRepository.SaveChangesAsync(cancellationToken); + + // 5. 记录日志 + logger.LogInformation("创建订单 {OrderNo} ({OrderId})", order.OrderNo, order.Id); + + // 6. 返回 DTO return MapToDto(order, items, [], []); } diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/DeleteOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/DeleteOrderCommandHandler.cs index d376e47..77e6e95 100644 --- a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/DeleteOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/DeleteOrderCommandHandler.cs @@ -15,26 +15,25 @@ public sealed class DeleteOrderCommandHandler( ILogger logger) : IRequestHandler { - private readonly IOrderRepository _orderRepository = orderRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(DeleteOrderCommand request, CancellationToken cancellationToken) { // 1. 校验存在性 - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); if (existing == null) { return false; } // 2. 删除 - await _orderRepository.DeleteOrderAsync(request.OrderId, tenantId, cancellationToken); - await _orderRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("删除订单 {OrderId}", request.OrderId); + await orderRepository.DeleteOrderAsync(request.OrderId, tenantId, cancellationToken); + await orderRepository.SaveChangesAsync(cancellationToken); + // 3. 记录日志 + logger.LogInformation("删除订单 {OrderId}", request.OrderId); + + // 4. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderByIdQueryHandler.cs index 5d6b791..3a45377 100644 --- a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/GetOrderByIdQueryHandler.cs @@ -15,23 +15,25 @@ public sealed class GetOrderByIdQueryHandler( ITenantProvider tenantProvider) : IRequestHandler { - private readonly IOrderRepository _orderRepository = orderRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task Handle(GetOrderByIdQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var order = await _orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); + // 1. 获取当前租户 + var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 查询订单主体 + var order = await orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); if (order == null) { return null; } - var items = await _orderRepository.GetItemsAsync(order.Id, tenantId, cancellationToken); - var histories = await _orderRepository.GetStatusHistoryAsync(order.Id, tenantId, cancellationToken); - var refunds = await _orderRepository.GetRefundsAsync(order.Id, tenantId, cancellationToken); + // 3. 查询关联明细 + var items = await orderRepository.GetItemsAsync(order.Id, tenantId, cancellationToken); + var histories = await orderRepository.GetStatusHistoryAsync(order.Id, tenantId, cancellationToken); + var refunds = await orderRepository.GetRefundsAsync(order.Id, tenantId, cancellationToken); + // 4. 映射并返回 return MapToDto(order, items, histories, refunds); } diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrdersQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrdersQueryHandler.cs index 867332b..90844c1 100644 --- a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrdersQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/SearchOrdersQueryHandler.cs @@ -15,20 +15,20 @@ public sealed class SearchOrdersQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IOrderRepository _orderRepository = orderRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task> Handle(SearchOrdersQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var orders = await _orderRepository.SearchAsync(tenantId, request.Status, request.PaymentStatus, cancellationToken); + // 1. 获取当前租户并查询订单 + var tenantId = tenantProvider.GetCurrentTenantId(); + var orders = await orderRepository.SearchAsync(tenantId, request.Status, request.PaymentStatus, cancellationToken); + // 2. 可选过滤:门店 if (request.StoreId.HasValue) { orders = orders.Where(x => x.StoreId == request.StoreId.Value).ToList(); } + // 3. 可选过滤:订单号模糊 if (!string.IsNullOrWhiteSpace(request.OrderNo)) { var orderNo = request.OrderNo.Trim(); @@ -37,12 +37,14 @@ public sealed class SearchOrdersQueryHandler( .ToList(); } + // 4. 排序与分页 var sorted = ApplySorting(orders, request.SortBy, request.SortDescending); var paged = sorted .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 5. 映射 DTO var items = paged.Select(order => new OrderDto { Id = order.Id, @@ -70,6 +72,7 @@ public sealed class SearchOrdersQueryHandler( CreatedAt = order.CreatedAt }).ToList(); + // 6. 返回分页结果 return new PagedResult(items, request.Page, request.PageSize, orders.Count); } diff --git a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/UpdateOrderCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/UpdateOrderCommandHandler.cs index 46df4ed..53f6c88 100644 --- a/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/UpdateOrderCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Orders/Handlers/UpdateOrderCommandHandler.cs @@ -17,16 +17,12 @@ public sealed class UpdateOrderCommandHandler( ILogger logger) : IRequestHandler { - private readonly IOrderRepository _orderRepository = orderRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - private readonly ILogger _logger = logger; - /// public async Task Handle(UpdateOrderCommand request, CancellationToken cancellationToken) { // 1. 读取订单 - var tenantId = _tenantProvider.GetCurrentTenantId(); - var existing = await _orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); + var tenantId = tenantProvider.GetCurrentTenantId(); + var existing = await orderRepository.FindByIdAsync(request.OrderId, tenantId, cancellationToken); if (existing == null) { return null; @@ -55,14 +51,16 @@ public sealed class UpdateOrderCommandHandler( existing.Remark = request.Remark?.Trim(); // 3. 持久化 - await _orderRepository.UpdateOrderAsync(existing, cancellationToken); - await _orderRepository.SaveChangesAsync(cancellationToken); - _logger.LogInformation("更新订单 {OrderNo} ({OrderId})", existing.OrderNo, existing.Id); + await orderRepository.UpdateOrderAsync(existing, cancellationToken); + await orderRepository.SaveChangesAsync(cancellationToken); - // 4. 读取关联数据并返回 - var items = await _orderRepository.GetItemsAsync(existing.Id, tenantId, cancellationToken); - var histories = await _orderRepository.GetStatusHistoryAsync(existing.Id, tenantId, cancellationToken); - var refunds = await _orderRepository.GetRefundsAsync(existing.Id, tenantId, cancellationToken); + // 4. 记录更新日志 + logger.LogInformation("更新订单 {OrderNo} ({OrderId})", existing.OrderNo, existing.Id); + + // 5. 读取关联数据并返回 + var items = await orderRepository.GetItemsAsync(existing.Id, tenantId, cancellationToken); + var histories = await orderRepository.GetStatusHistoryAsync(existing.Id, tenantId, cancellationToken); + var refunds = await orderRepository.GetRefundsAsync(existing.Id, tenantId, cancellationToken); return MapToDto(existing, items, histories, refunds); } diff --git a/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/SearchPaymentsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/SearchPaymentsQueryHandler.cs index 9d6c4ae..a265022 100644 --- a/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/SearchPaymentsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Payments/Handlers/SearchPaymentsQueryHandler.cs @@ -1,7 +1,6 @@ using MediatR; using TakeoutSaaS.Application.App.Payments.Dto; using TakeoutSaaS.Application.App.Payments.Queries; -using TakeoutSaaS.Domain.Payments.Entities; using TakeoutSaaS.Domain.Payments.Repositories; using TakeoutSaaS.Shared.Abstractions.Results; using TakeoutSaaS.Shared.Abstractions.Tenancy; diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/PublishProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/PublishProductCommand.cs new file mode 100644 index 0000000..f8aa134 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/PublishProductCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 上架商品命令。 +/// +public sealed record PublishProductCommand : IRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 上架备注。 + /// + public string? Reason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAddonsCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAddonsCommand.cs new file mode 100644 index 0000000..41ec401 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAddonsCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品加料命令。 +/// +public sealed record ReplaceProductAddonsCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 加料组。 + /// + public IReadOnlyList AddonGroups { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAttributesCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAttributesCommand.cs new file mode 100644 index 0000000..4ec120f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductAttributesCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品规格命令。 +/// +public sealed record ReplaceProductAttributesCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 规格组。 + /// + public IReadOnlyList AttributeGroups { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductMediaCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductMediaCommand.cs new file mode 100644 index 0000000..6e099a0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductMediaCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品媒资命令。 +/// +public sealed record ReplaceProductMediaCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 媒资列表。 + /// + public IReadOnlyList MediaAssets { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductPricingRulesCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductPricingRulesCommand.cs new file mode 100644 index 0000000..067b11f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductPricingRulesCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品价格策略命令。 +/// +public sealed record ReplaceProductPricingRulesCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 价格策略。 + /// + public IReadOnlyList PricingRules { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductSkusCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductSkusCommand.cs new file mode 100644 index 0000000..8f82fdf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/ReplaceProductSkusCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 替换商品 SKU 命令。 +/// +public sealed record ReplaceProductSkusCommand : IRequest> +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// SKU 列表。 + /// + public IReadOnlyList Skus { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Commands/UnpublishProductCommand.cs b/src/Application/TakeoutSaaS.Application/App/Products/Commands/UnpublishProductCommand.cs new file mode 100644 index 0000000..d59aef5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Commands/UnpublishProductCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Commands; + +/// +/// 下架商品命令。 +/// +public sealed record UnpublishProductCommand : IRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } + + /// + /// 下架原因。 + /// + public string? Reason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonGroupDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonGroupDto.cs new file mode 100644 index 0000000..c249464 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonGroupDto.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 加料组 DTO。 +/// +public sealed record ProductAddonGroupDto +{ + /// + /// 组 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 最小选择数。 + /// + public int MinSelect { get; init; } + + /// + /// 最大选择数。 + /// + public int MaxSelect { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } + + /// + /// 加料选项。 + /// + public IReadOnlyList Options { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonOptionDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonOptionDto.cs new file mode 100644 index 0000000..544ba67 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAddonOptionDto.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 加料选项 DTO。 +/// +public sealed record ProductAddonOptionDto +{ + /// + /// 选项 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 所属加料组 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long AddonGroupId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 额外价格。 + /// + public decimal? ExtraPrice { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeGroupDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeGroupDto.cs new file mode 100644 index 0000000..7e5ce67 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeGroupDto.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 规格组 DTO。 +/// +public sealed record ProductAttributeGroupDto +{ + /// + /// 组 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 选择类型。 + /// + public int SelectionType { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } + + /// + /// 规格选项。 + /// + public IReadOnlyList Options { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeOptionDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeOptionDto.cs new file mode 100644 index 0000000..f9fc8ce --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductAttributeOptionDto.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 规格选项 DTO。 +/// +public sealed record ProductAttributeOptionDto +{ + /// + /// 选项 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 规格组 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long AttributeGroupId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 排序。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryMenuDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryMenuDto.cs new file mode 100644 index 0000000..d758957 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductCategoryMenuDto.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 门店菜单分类 DTO。 +/// +public sealed record ProductCategoryMenuDto +{ + /// + /// 分类 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 分类名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 分类描述。 + /// + public string? Description { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; init; } + + /// + /// 分类下商品列表。 + /// + public IReadOnlyList Products { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDetailDto.cs new file mode 100644 index 0000000..cc9ec59 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDetailDto.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 商品全量详情 DTO。 +/// +public sealed record ProductDetailDto +{ + /// + /// SPU 基础信息。 + /// + public ProductDto Product { get; init; } = new(); + + /// + /// SKU 列表。 + /// + public IReadOnlyList Skus { get; init; } = []; + + /// + /// 规格组与选项。 + /// + public IReadOnlyList AttributeGroups { get; init; } = []; + + /// + /// 加料组与选项。 + /// + public IReadOnlyList AddonGroups { get; init; } = []; + + /// + /// 价格策略。 + /// + public IReadOnlyList PricingRules { get; init; } = []; + + /// + /// 媒资列表。 + /// + public IReadOnlyList MediaAssets { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs index bfcd321..27adb83 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductDto.cs @@ -5,7 +5,7 @@ using TakeoutSaaS.Shared.Abstractions.Serialization; namespace TakeoutSaaS.Application.App.Products.Dto; /// -/// 商品 DTO。 +/// 商品 DTO(含 SPU 基础信息)。 /// public sealed class ProductDto { diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductMediaAssetDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductMediaAssetDto.cs new file mode 100644 index 0000000..d4a3279 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductMediaAssetDto.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 商品媒资 DTO。 +/// +public sealed record ProductMediaAssetDto +{ + /// + /// 媒资 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 类型。 + /// + public MediaAssetType MediaType { get; init; } + + /// + /// URL。 + /// + public string Url { get; init; } = string.Empty; + + /// + /// 文案。 + /// + public string? Caption { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductPricingRuleDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductPricingRuleDto.cs new file mode 100644 index 0000000..04961fb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductPricingRuleDto.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 价格策略 DTO。 +/// +public sealed record ProductPricingRuleDto +{ + /// + /// 策略 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 策略类型。 + /// + public PricingRuleType RuleType { get; init; } + + /// + /// 价格。 + /// + public decimal Price { get; init; } + + /// + /// 条件 JSON。 + /// + public string ConditionsJson { get; init; } = string.Empty; + + /// + /// 星期规则。 + /// + public string? WeekdaysJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs new file mode 100644 index 0000000..4b76869 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/ProductSkuDto.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// SKU DTO。 +/// +public sealed record ProductSkuDto +{ + /// + /// SKU ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 商品 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long ProductId { get; init; } + + /// + /// 编码。 + /// + public string SkuCode { get; init; } = string.Empty; + + /// + /// 条形码。 + /// + public string? Barcode { get; init; } + + /// + /// 售价。 + /// + public decimal Price { get; init; } + + /// + /// 原价。 + /// + public decimal? OriginalPrice { get; init; } + + /// + /// 库存。 + /// + public int? StockQuantity { get; init; } + + /// + /// 重量。 + /// + public decimal? Weight { get; init; } + + /// + /// 规格属性 JSON。 + /// + public string AttributesJson { get; init; } = string.Empty; + + /// + /// 排序。 + /// + public int SortOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Dto/StoreMenuDto.cs b/src/Application/TakeoutSaaS.Application/App/Products/Dto/StoreMenuDto.cs new file mode 100644 index 0000000..5429d43 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Dto/StoreMenuDto.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Products.Dto; + +/// +/// 门店菜单数据传输对象。 +/// +public sealed record StoreMenuDto +{ + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 分类与商品集合。 + /// + public IReadOnlyList Categories { get; init; } = []; + + /// + /// 菜单生成时间(UTC)。 + /// + public DateTime GeneratedAt { get; init; } + + /// + /// 客户端请求的增量时间(UTC)。 + /// + public DateTime? UpdatedAfter { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs index c3b6a60..bed5199 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductByIdQueryHandler.cs @@ -23,31 +23,6 @@ public sealed class GetProductByIdQueryHandler( { var tenantId = _tenantProvider.GetCurrentTenantId(); var product = await _productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); - return product == null ? null : MapToDto(product); + return product == null ? null : ProductMapping.ToDto(product); } - - private static ProductDto MapToDto(Product product) => new() - { - Id = product.Id, - TenantId = product.TenantId, - StoreId = product.StoreId, - CategoryId = product.CategoryId, - SpuCode = product.SpuCode, - Name = product.Name, - Subtitle = product.Subtitle, - Unit = product.Unit, - Price = product.Price, - OriginalPrice = product.OriginalPrice, - StockQuantity = product.StockQuantity, - MaxQuantityPerOrder = product.MaxQuantityPerOrder, - Status = product.Status, - CoverImage = product.CoverImage, - GalleryImages = product.GalleryImages, - Description = product.Description, - EnableDineIn = product.EnableDineIn, - EnablePickup = product.EnablePickup, - EnableDelivery = product.EnableDelivery, - IsFeatured = product.IsFeatured, - CreatedAt = product.CreatedAt - }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs new file mode 100644 index 0000000..828f1c8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetProductDetailQueryHandler.cs @@ -0,0 +1,63 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Application.App.Products.Queries; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 商品全量详情查询处理器。 +/// +public sealed class GetProductDetailQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(GetProductDetailQuery request, CancellationToken cancellationToken) + { + // 1. 读取 SPU + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + return null; + } + + // 2. 查询子项 + var skusTask = productRepository.GetSkusAsync(product.Id, tenantId, cancellationToken); + var attrGroupsTask = productRepository.GetAttributeGroupsAsync(product.Id, tenantId, cancellationToken); + var attrOptionsTask = productRepository.GetAttributeOptionsAsync(product.Id, tenantId, cancellationToken); + var addonGroupsTask = productRepository.GetAddonGroupsAsync(product.Id, tenantId, cancellationToken); + var addonOptionsTask = productRepository.GetAddonOptionsAsync(product.Id, tenantId, cancellationToken); + var mediaTask = productRepository.GetMediaAssetsAsync(product.Id, tenantId, cancellationToken); + var pricingTask = productRepository.GetPricingRulesAsync(product.Id, tenantId, cancellationToken); + + await Task.WhenAll(skusTask, attrGroupsTask, attrOptionsTask, addonGroupsTask, addonOptionsTask, mediaTask, pricingTask); + + // 3. 组装 DTO + var skus = await skusTask; + var attrGroups = await attrGroupsTask; + var attrOptions = (await attrOptionsTask).ToLookup(x => x.AttributeGroupId); + var addonGroups = await addonGroupsTask; + var addonOptions = (await addonOptionsTask).ToLookup(x => x.AddonGroupId); + var mediaAssets = await mediaTask; + var pricingRules = await pricingTask; + var detail = new ProductDetailDto + { + Product = ProductMapping.ToDto(product), + Skus = skus.Select(ProductMapping.ToDto).ToList(), + AttributeGroups = attrGroups + .Select(g => ProductMapping.ToDto(g, attrOptions[g.Id].ToList())) + .ToList(), + AddonGroups = addonGroups + .Select(g => ProductMapping.ToDto(g, addonOptions[g.Id].ToList())) + .ToList(), + MediaAssets = mediaAssets.Select(ProductMapping.ToDto).ToList(), + PricingRules = pricingRules.Select(ProductMapping.ToDto).ToList() + }; + + return detail; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetStoreMenuQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetStoreMenuQueryHandler.cs new file mode 100644 index 0000000..bde1465 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/GetStoreMenuQueryHandler.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Application.App.Products.Queries; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 门店菜单查询处理器。 +/// +public sealed class GetStoreMenuQueryHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(GetStoreMenuQuery request, CancellationToken cancellationToken) + { + // 1. 准备上下文 + var tenantId = tenantProvider.GetCurrentTenantId(); + var updatedAfterUtc = request.UpdatedAfter?.ToUniversalTime(); + // 2. 获取分类 + var categories = await productRepository.GetCategoriesByStoreAsync(tenantId, request.StoreId, true, cancellationToken); + // 3. 读取上架商品(支持增量) + var products = await productRepository.SearchAsync(tenantId, request.StoreId, null, ProductStatus.OnSale, cancellationToken, updatedAfterUtc); + if (products.Count == 0) + { + logger.LogInformation("门店 {StoreId} 没有上架商品,返回空菜单", request.StoreId); + return new StoreMenuDto + { + StoreId = request.StoreId, + GeneratedAt = DateTime.UtcNow, + UpdatedAfter = updatedAfterUtc, + Categories = categories + .OrderBy(x => x.SortOrder) + .Select(category => new ProductCategoryMenuDto + { + Id = category.Id, + StoreId = category.StoreId, + Name = category.Name, + Description = category.Description, + SortOrder = category.SortOrder, + IsEnabled = category.IsEnabled, + Products = [] + }) + .ToList() + }; + } + + // 4. 并发加载子表数据 + var productIds = products.Select(x => x.Id).ToList(); + var skusTask = productRepository.GetSkusByProductIdsAsync(productIds, tenantId, cancellationToken); + var attributeGroupsTask = productRepository.GetAttributeGroupsByProductIdsAsync(productIds, tenantId, cancellationToken); + var addonGroupsTask = productRepository.GetAddonGroupsByProductIdsAsync(productIds, tenantId, cancellationToken); + var mediaTask = productRepository.GetMediaAssetsByProductIdsAsync(productIds, tenantId, cancellationToken); + var pricingTask = productRepository.GetPricingRulesByProductIdsAsync(productIds, tenantId, cancellationToken); + await Task.WhenAll(skusTask, attributeGroupsTask, addonGroupsTask, mediaTask, pricingTask); + var attributeGroups = await attributeGroupsTask; + var addonGroups = await addonGroupsTask; + // 批量读取规格与加料选项 + var attributeOptionsTask = attributeGroups.Count == 0 + ? Task.FromResult>(Array.Empty()) + : productRepository.GetAttributeOptionsByGroupIdsAsync(attributeGroups.Select(x => x.Id).ToList(), tenantId, cancellationToken); + var addonOptionsTask = addonGroups.Count == 0 + ? Task.FromResult>(Array.Empty()) + : productRepository.GetAddonOptionsByGroupIdsAsync(addonGroups.Select(x => x.Id).ToList(), tenantId, cancellationToken); + await Task.WhenAll(attributeOptionsTask, addonOptionsTask); + + // 5. 建立查找表 + var skuLookup = (await skusTask).ToLookup(x => x.ProductId); + var attrGroupLookup = attributeGroups.ToLookup(x => x.ProductId); + var attrOptionLookup = (await attributeOptionsTask).ToLookup(x => x.AttributeGroupId); + var addonGroupLookup = addonGroups.ToLookup(x => x.ProductId); + var addonOptionLookup = (await addonOptionsTask).ToLookup(x => x.AddonGroupId); + var mediaLookup = (await mediaTask).ToLookup(x => x.ProductId); + var pricingLookup = (await pricingTask).ToLookup(x => x.ProductId); + // 6. 组装商品详情 + var productDetails = products.ToDictionary( + product => product.Id, + product => + { + var attributeDtos = attrGroupLookup[product.Id] + .Select(group => ProductMapping.ToDto(group, attrOptionLookup[group.Id].ToList())) + .ToList(); + var addonDtos = addonGroupLookup[product.Id] + .Select(group => ProductMapping.ToDto(group, addonOptionLookup[group.Id].ToList())) + .ToList(); + return new ProductDetailDto + { + Product = ProductMapping.ToDto(product), + Skus = skuLookup[product.Id].Select(ProductMapping.ToDto).ToList(), + AttributeGroups = attributeDtos, + AddonGroups = addonDtos, + MediaAssets = mediaLookup[product.Id].Select(ProductMapping.ToDto).ToList(), + PricingRules = pricingLookup[product.Id].Select(ProductMapping.ToDto).ToList() + }; + }); + // 7. 组装分类菜单 + var productsByCategory = products.ToLookup(x => x.CategoryId); + var categoryMenu = categories + .OrderBy(x => x.SortOrder) + .Select(category => + { + var categoryProducts = productsByCategory[category.Id] + .Select(p => productDetails[p.Id]) + .ToList(); + return new ProductCategoryMenuDto + { + Id = category.Id, + StoreId = category.StoreId, + Name = category.Name, + Description = category.Description, + SortOrder = category.SortOrder, + IsEnabled = category.IsEnabled, + Products = categoryProducts + }; + }) + .ToList(); + return new StoreMenuDto + { + StoreId = request.StoreId, + GeneratedAt = DateTime.UtcNow, + UpdatedAfter = updatedAfterUtc, + Categories = categoryMenu + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/PublishProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/PublishProductCommandHandler.cs new file mode 100644 index 0000000..fd71ada --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/PublishProductCommandHandler.cs @@ -0,0 +1,49 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Application.App.Products.Queries; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 商品上架处理器。 +/// +public sealed class PublishProductCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(PublishProductCommand request, CancellationToken cancellationToken) + { + // 1. 读取商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + return null; + } + + // 2. 校验 SKU 可售 + var skus = await productRepository.GetSkusAsync(product.Id, tenantId, cancellationToken); + if (skus.Count == 0) + { + throw new BusinessException(ErrorCodes.Conflict, "请先配置可售 SKU 后再上架"); + } + + // 3. 上架 + product.Status = ProductStatus.OnSale; + await productRepository.UpdateProductAsync(product, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("商品上架 {ProductId}", product.Id); + + return ProductMapping.ToDto(product); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAddonsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAddonsCommandHandler.cs new file mode 100644 index 0000000..af61144 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAddonsCommandHandler.cs @@ -0,0 +1,74 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 替换加料处理器。 +/// +public sealed class ReplaceProductAddonsCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(ReplaceProductAddonsCommand request, CancellationToken cancellationToken) + { + // 1. 校验商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + throw new BusinessException(ErrorCodes.NotFound, "商品不存在"); + } + + // 2. 校验组名唯一 + var names = request.AddonGroups.Select(x => x.Name.Trim()).ToList(); + if (names.Count != names.Distinct(StringComparer.OrdinalIgnoreCase).Count()) + { + throw new BusinessException(ErrorCodes.Conflict, "加料组名称重复"); + } + + // 3. 替换 + await productRepository.RemoveAddonGroupsAsync(request.ProductId, tenantId, cancellationToken); + // 重新插入组 + var groupEntities = request.AddonGroups.Select(g => new ProductAddonGroup + { + ProductId = request.ProductId, + Name = g.Name.Trim(), + MinSelect = g.MinSelect, + MaxSelect = g.MaxSelect, + SortOrder = g.SortOrder + }).ToList(); + await productRepository.AddAddonGroupsAsync(groupEntities, [], cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + // 重新建立组与请求的映射 + var groupIdLookup = groupEntities.Zip(request.AddonGroups, (entity, dto) => (entity, dto)) + .ToDictionary(x => x.dto, x => x.entity.Id); + // 构建选项实体 + var optionEntities = request.AddonGroups + .SelectMany(dto => dto.Options.Select(o => new ProductAddonOption + { + AddonGroupId = groupIdLookup[dto], + Name = o.Name.Trim(), + ExtraPrice = o.ExtraPrice, + SortOrder = o.SortOrder + })) + .ToList(); + await productRepository.AddAddonGroupsAsync([], optionEntities, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("替换商品 {ProductId} 加料组 {Count} 个", request.ProductId, groupEntities.Count); + + return groupEntities + .Select(g => ProductMapping.ToDto(g, optionEntities.Where(o => o.AddonGroupId == g.Id).ToList())) + .ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAttributesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAttributesCommandHandler.cs new file mode 100644 index 0000000..9cef4d1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductAttributesCommandHandler.cs @@ -0,0 +1,77 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 替换规格处理器。 +/// +public sealed class ReplaceProductAttributesCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(ReplaceProductAttributesCommand request, CancellationToken cancellationToken) + { + // 1. 校验商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + throw new BusinessException(ErrorCodes.NotFound, "商品不存在"); + } + + // 2. 组名唯一 + var groupNames = request.AttributeGroups.Select(x => x.Name.Trim()).ToList(); + if (groupNames.Count != groupNames.Distinct(StringComparer.OrdinalIgnoreCase).Count()) + { + throw new BusinessException(ErrorCodes.Conflict, "规格组名称重复"); + } + + // 3. 替换 + await productRepository.RemoveAttributeGroupsAsync(request.ProductId, tenantId, cancellationToken); + + var groupEntities = request.AttributeGroups.Select(g => new ProductAttributeGroup + { + ProductId = request.ProductId, + Name = g.Name.Trim(), + SelectionType = (Domain.Products.Enums.AttributeSelectionType)g.SelectionType, + SortOrder = g.SortOrder + }).ToList(); + + // 4. 持久化(分批保障 FK 正确) + await productRepository.AddAttributeGroupsAsync(groupEntities, [], cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + + // 重新建立选项的 GroupId 映射 + var groupIdLookup = groupEntities.Zip(request.AttributeGroups, (entity, dto) => (entity, dto)) + .ToDictionary(x => x.dto, x => x.entity.Id); + + var optionEntities = request.AttributeGroups + .SelectMany(dto => dto.Options.Select(o => new ProductAttributeOption + { + AttributeGroupId = groupIdLookup[dto], + Name = o.Name.Trim(), + SortOrder = o.SortOrder + })) + .ToList(); + + await productRepository.AddAttributeGroupsAsync([], optionEntities, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("替换商品 {ProductId} 规格组 {GroupCount} 个", request.ProductId, groupEntities.Count); + + // 5. 返回 DTO + return groupEntities + .Select(g => ProductMapping.ToDto(g, optionEntities.Where(o => o.AttributeGroupId == g.Id).ToList())) + .ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductMediaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductMediaCommandHandler.cs new file mode 100644 index 0000000..4c704f4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductMediaCommandHandler.cs @@ -0,0 +1,51 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 替换媒资处理器。 +/// +public sealed class ReplaceProductMediaCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(ReplaceProductMediaCommand request, CancellationToken cancellationToken) + { + // 1. 校验商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + throw new BusinessException(ErrorCodes.NotFound, "商品不存在"); + } + + // 2. 替换 + await productRepository.RemoveMediaAssetsAsync(request.ProductId, tenantId, cancellationToken); + + var assets = request.MediaAssets.Select(a => new ProductMediaAsset + { + ProductId = request.ProductId, + MediaType = a.MediaType, + Url = a.Url.Trim(), + Caption = a.Caption?.Trim(), + SortOrder = a.SortOrder + }).ToList(); + + await productRepository.AddMediaAssetsAsync(assets, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("替换商品 {ProductId} 媒资 {Count} 条", request.ProductId, assets.Count); + + return assets.Select(ProductMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductPricingRulesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductPricingRulesCommandHandler.cs new file mode 100644 index 0000000..8c02739 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductPricingRulesCommandHandler.cs @@ -0,0 +1,52 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 替换价格策略处理器。 +/// +public sealed class ReplaceProductPricingRulesCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(ReplaceProductPricingRulesCommand request, CancellationToken cancellationToken) + { + // 1. 校验商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + throw new BusinessException(ErrorCodes.NotFound, "商品不存在"); + } + + // 2. 替换 + await productRepository.RemovePricingRulesAsync(request.ProductId, tenantId, cancellationToken); + + var rules = request.PricingRules.Select(r => new ProductPricingRule + { + ProductId = request.ProductId, + RuleType = r.RuleType, + ConditionsJson = r.ConditionsJson.Trim(), + Price = r.Price, + WeekdaysJson = r.WeekdaysJson, + SortOrder = 0 + }).ToList(); + + await productRepository.AddPricingRulesAsync(rules, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("替换商品 {ProductId} 价格策略 {Count} 条", request.ProductId, rules.Count); + + return rules.Select(ProductMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductSkusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductSkusCommandHandler.cs new file mode 100644 index 0000000..9f62d45 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/ReplaceProductSkusCommandHandler.cs @@ -0,0 +1,61 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 替换 SKU 处理器。 +/// +public sealed class ReplaceProductSkusCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(ReplaceProductSkusCommand request, CancellationToken cancellationToken) + { + // 1. 校验商品存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + throw new BusinessException(ErrorCodes.NotFound, "商品不存在"); + } + + // 2. 校验 SKU 唯一性 + var codes = request.Skus.Select(x => x.SkuCode.Trim()).ToList(); + if (codes.Count != codes.Distinct(StringComparer.OrdinalIgnoreCase).Count()) + { + throw new BusinessException(ErrorCodes.Conflict, "SKU 编码重复"); + } + + // 3. 替换 + await productRepository.RemoveSkusAsync(request.ProductId, tenantId, cancellationToken); + var entities = request.Skus.Select(x => new ProductSku + { + ProductId = request.ProductId, + SkuCode = x.SkuCode.Trim(), + Barcode = x.Barcode?.Trim(), + Price = x.Price, + OriginalPrice = x.OriginalPrice, + StockQuantity = x.StockQuantity, + Weight = x.Weight, + AttributesJson = x.AttributesJson ?? string.Empty, + SortOrder = x.SortOrder + }).ToList(); + + await productRepository.AddSkusAsync(entities, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("替换商品 {ProductId} 的 SKU 数量 {Count}", request.ProductId, entities.Count); + + return entities.Select(ProductMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs index 70e91f2..660a35b 100644 --- a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/SearchProductsQueryHandler.cs @@ -22,12 +22,7 @@ public sealed class SearchProductsQueryHandler( public async Task> Handle(SearchProductsQuery request, CancellationToken cancellationToken) { var tenantId = _tenantProvider.GetCurrentTenantId(); - var products = await _productRepository.SearchAsync(tenantId, request.CategoryId, request.Status, cancellationToken); - - if (request.StoreId.HasValue) - { - products = products.Where(x => x.StoreId == request.StoreId.Value).ToList(); - } + var products = await _productRepository.SearchAsync(tenantId, request.StoreId, request.CategoryId, request.Status, cancellationToken); var sorted = ApplySorting(products, request.SortBy, request.SortDescending); var paged = sorted diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UnpublishProductCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UnpublishProductCommandHandler.cs new file mode 100644 index 0000000..83fe4c8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Handlers/UnpublishProductCommandHandler.cs @@ -0,0 +1,39 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Products.Commands; +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Enums; +using TakeoutSaaS.Domain.Products.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Products.Handlers; + +/// +/// 商品下架处理器。 +/// +public sealed class UnpublishProductCommandHandler( + IProductRepository productRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UnpublishProductCommand request, CancellationToken cancellationToken) + { + // 1. 读取商品 + var tenantId = tenantProvider.GetCurrentTenantId(); + var product = await productRepository.FindByIdAsync(request.ProductId, tenantId, cancellationToken); + if (product is null) + { + return null; + } + + // 2. 下架 + product.Status = ProductStatus.OffShelf; + await productRepository.UpdateProductAsync(product, cancellationToken); + await productRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("商品下架 {ProductId}", product.Id); + + return ProductMapping.ToDto(product); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs b/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs new file mode 100644 index 0000000..77cf89f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/ProductMapping.cs @@ -0,0 +1,133 @@ +using TakeoutSaaS.Application.App.Products.Dto; +using TakeoutSaaS.Domain.Products.Entities; + +namespace TakeoutSaaS.Application.App.Products; + +/// +/// 商品映射辅助。 +/// +public static class ProductMapping +{ + /// + /// 映射 SPU DTO。 + /// + /// 商品实体。 + /// DTO。 + public static ProductDto ToDto(Product product) => new() + { + Id = product.Id, + TenantId = product.TenantId, + StoreId = product.StoreId, + CategoryId = product.CategoryId, + SpuCode = product.SpuCode, + Name = product.Name, + Subtitle = product.Subtitle, + Unit = product.Unit, + Price = product.Price, + OriginalPrice = product.OriginalPrice, + StockQuantity = product.StockQuantity, + MaxQuantityPerOrder = product.MaxQuantityPerOrder, + Status = product.Status, + CoverImage = product.CoverImage, + GalleryImages = product.GalleryImages, + Description = product.Description, + EnableDineIn = product.EnableDineIn, + EnablePickup = product.EnablePickup, + EnableDelivery = product.EnableDelivery, + IsFeatured = product.IsFeatured, + CreatedAt = product.CreatedAt + }; + + /// + /// 映射 SKU DTO。 + /// + public static ProductSkuDto ToDto(ProductSku sku) => new() + { + Id = sku.Id, + ProductId = sku.ProductId, + SkuCode = sku.SkuCode, + Barcode = sku.Barcode, + Price = sku.Price, + OriginalPrice = sku.OriginalPrice, + StockQuantity = sku.StockQuantity, + Weight = sku.Weight, + AttributesJson = sku.AttributesJson, + SortOrder = sku.SortOrder + }; + + /// + /// 映射规格组 DTO。 + /// + public static ProductAttributeGroupDto ToDto(ProductAttributeGroup group, IReadOnlyList options) => new() + { + Id = group.Id, + ProductId = group.ProductId, + Name = group.Name, + SelectionType = (int)group.SelectionType, + SortOrder = group.SortOrder, + Options = options.Select(ToDto).ToList() + }; + + /// + /// 映射规格选项 DTO。 + /// + public static ProductAttributeOptionDto ToDto(ProductAttributeOption option) => new() + { + Id = option.Id, + AttributeGroupId = option.AttributeGroupId, + Name = option.Name, + SortOrder = option.SortOrder + }; + + /// + /// 映射加料组 DTO。 + /// + public static ProductAddonGroupDto ToDto(ProductAddonGroup group, IReadOnlyList options) => new() + { + Id = group.Id, + ProductId = group.ProductId, + Name = group.Name, + MinSelect = group.MinSelect ?? 0, + MaxSelect = group.MaxSelect ?? 0, + SortOrder = group.SortOrder, + Options = options.Select(ToDto).ToList() + }; + + /// + /// 映射加料选项 DTO。 + /// + public static ProductAddonOptionDto ToDto(ProductAddonOption option) => new() + { + Id = option.Id, + AddonGroupId = option.AddonGroupId, + Name = option.Name, + ExtraPrice = option.ExtraPrice, + SortOrder = option.SortOrder + }; + + /// + /// 映射媒资 DTO。 + /// + public static ProductMediaAssetDto ToDto(ProductMediaAsset asset) => new() + { + Id = asset.Id, + ProductId = asset.ProductId, + MediaType = asset.MediaType, + Url = asset.Url, + Caption = asset.Caption, + SortOrder = asset.SortOrder + }; + + /// + /// 映射价格策略 DTO。 + /// + public static ProductPricingRuleDto ToDto(ProductPricingRule rule) => new() + { + Id = rule.Id, + ProductId = rule.ProductId, + RuleType = rule.RuleType, + Price = rule.Price, + ConditionsJson = rule.ConditionsJson, + WeekdaysJson = rule.WeekdaysJson + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductDetailQuery.cs b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductDetailQuery.cs new file mode 100644 index 0000000..a6e2c19 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetProductDetailQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Queries; + +/// +/// 商品全量详情查询。 +/// +public sealed record GetProductDetailQuery : IRequest +{ + /// + /// 商品 ID。 + /// + public long ProductId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetStoreMenuQuery.cs b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetStoreMenuQuery.cs new file mode 100644 index 0000000..472806b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Queries/GetStoreMenuQuery.cs @@ -0,0 +1,21 @@ +using System; +using MediatR; +using TakeoutSaaS.Application.App.Products.Dto; + +namespace TakeoutSaaS.Application.App.Products.Queries; + +/// +/// 获取门店菜单查询。 +/// +public sealed record GetStoreMenuQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 增量时间(UTC)。 + /// + public DateTime? UpdatedAfter { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/PublishProductCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/PublishProductCommandValidator.cs new file mode 100644 index 0000000..4964237 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/PublishProductCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 上架商品命令验证器。 +/// +public sealed class PublishProductCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public PublishProductCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.Reason).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAddonsCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAddonsCommandValidator.cs new file mode 100644 index 0000000..37f52a8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAddonsCommandValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换加料验证器。 +/// +public sealed class ReplaceProductAddonsCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReplaceProductAddonsCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleForEach(x => x.AddonGroups).ChildRules(group => + { + group.RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + group.RuleFor(x => x.MinSelect).GreaterThanOrEqualTo(0); + group.RuleFor(x => x.MaxSelect).GreaterThanOrEqualTo(x => x.MinSelect); + group.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + group.RuleForEach(x => x.Options).ChildRules(opt => + { + opt.RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + opt.RuleFor(x => x.ExtraPrice).GreaterThanOrEqualTo(0).When(x => x.ExtraPrice.HasValue); + opt.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + }); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAttributesCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAttributesCommandValidator.cs new file mode 100644 index 0000000..7cc5e10 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductAttributesCommandValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换规格验证器。 +/// +public sealed class ReplaceProductAttributesCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReplaceProductAttributesCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleForEach(x => x.AttributeGroups).ChildRules(group => + { + group.RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + group.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + group.RuleForEach(x => x.Options).ChildRules(opt => + { + opt.RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + opt.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + }); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductMediaCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductMediaCommandValidator.cs new file mode 100644 index 0000000..fa27cb7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductMediaCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换媒资验证器。 +/// +public sealed class ReplaceProductMediaCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReplaceProductMediaCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleForEach(x => x.MediaAssets).ChildRules(asset => + { + asset.RuleFor(x => x.Url).NotEmpty().MaximumLength(512); + asset.RuleFor(x => x.Caption).MaximumLength(256); + asset.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductPricingRulesCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductPricingRulesCommandValidator.cs new file mode 100644 index 0000000..5cdac59 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductPricingRulesCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换价格策略验证器。 +/// +public sealed class ReplaceProductPricingRulesCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReplaceProductPricingRulesCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleForEach(x => x.PricingRules).ChildRules(rule => + { + rule.RuleFor(x => x.Price).GreaterThan(0); + rule.RuleFor(x => x.ConditionsJson).NotEmpty(); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductSkusCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductSkusCommandValidator.cs new file mode 100644 index 0000000..7e4a387 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/ReplaceProductSkusCommandValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 替换 SKU 验证器。 +/// +public sealed class ReplaceProductSkusCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public ReplaceProductSkusCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleForEach(x => x.Skus).ChildRules(sku => + { + sku.RuleFor(x => x.SkuCode).NotEmpty().MaximumLength(64); + sku.RuleFor(x => x.Price).GreaterThan(0); + sku.RuleFor(x => x.OriginalPrice).GreaterThan(0).When(x => x.OriginalPrice.HasValue); + sku.RuleFor(x => x.StockQuantity).GreaterThanOrEqualTo(0).When(x => x.StockQuantity.HasValue); + sku.RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Products/Validators/UnpublishProductCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Products/Validators/UnpublishProductCommandValidator.cs new file mode 100644 index 0000000..1ee952c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Products/Validators/UnpublishProductCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Products.Commands; + +namespace TakeoutSaaS.Application.App.Products.Validators; + +/// +/// 下架商品命令验证器。 +/// +public sealed class UnpublishProductCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UnpublishProductCommandValidator() + { + RuleFor(x => x.ProductId).GreaterThan(0); + RuleFor(x => x.Reason).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreBusinessHourCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreBusinessHourCommand.cs new file mode 100644 index 0000000..345e990 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreBusinessHourCommand.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建营业时段命令。 +/// +public sealed record CreateStoreBusinessHourCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 星期几。 + /// + public DayOfWeek DayOfWeek { get; init; } + + /// + /// 时段类型。 + /// + public BusinessHourType HourType { get; init; } = BusinessHourType.Normal; + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 容量限制。 + /// + public int? CapacityLimit { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs index 21eb9c5..3315f92 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreCommand.cs @@ -98,4 +98,14 @@ public sealed class CreateStoreCommand : IRequest /// 支持配送。 /// public bool SupportsDelivery { get; set; } = true; + + /// + /// 支持预约。 + /// + public bool SupportsReservation { get; set; } + + /// + /// 支持排队叫号。 + /// + public bool SupportsQueueing { get; set; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreDeliveryZoneCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreDeliveryZoneCommand.cs new file mode 100644 index 0000000..af1af08 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreDeliveryZoneCommand.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建配送区域命令。 +/// +public sealed record CreateStoreDeliveryZoneCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域名称。 + /// + public string ZoneName { get; init; } = string.Empty; + + /// + /// GeoJSON。 + /// + public string PolygonGeoJson { get; init; } = string.Empty; + + /// + /// 起送价。 + /// + public decimal? MinimumOrderAmount { get; init; } + + /// + /// 配送费。 + /// + public decimal? DeliveryFee { get; init; } + + /// + /// 预计分钟。 + /// + public int? EstimatedMinutes { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } = 100; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreEmployeeShiftCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreEmployeeShiftCommand.cs new file mode 100644 index 0000000..2729a6f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreEmployeeShiftCommand.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建员工排班命令。 +/// +public sealed record CreateStoreEmployeeShiftCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 员工 ID。 + /// + public long StaffId { get; init; } + + /// + /// 班次日期。 + /// + public DateTime ShiftDate { get; init; } + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 排班角色。 + /// + public StaffRoleType RoleType { get; init; } = StaffRoleType.FrontDesk; + + /// + /// 备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreHolidayCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreHolidayCommand.cs new file mode 100644 index 0000000..896c090 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreHolidayCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建节假日配置命令。 +/// +public sealed record CreateStoreHolidayCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 日期。 + /// + public DateTime Date { get; init; } + + /// + /// 是否闭店。 + /// + public bool IsClosed { get; init; } = true; + + /// + /// 说明。 + /// + public string? Reason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStorePickupSlotCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStorePickupSlotCommand.cs new file mode 100644 index 0000000..0de3fa9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStorePickupSlotCommand.cs @@ -0,0 +1,50 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建自提档期命令。 +/// +public sealed record CreateStorePickupSlotCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 截单分钟。 + /// + public int CutoffMinutes { get; init; } = 30; + + /// + /// 容量。 + /// + public int Capacity { get; init; } + + /// + /// 适用星期。 + /// + public string Weekdays { get; init; } = string.Empty; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreStaffCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreStaffCommand.cs new file mode 100644 index 0000000..26dce0d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreStaffCommand.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建门店员工命令。 +/// +public sealed record CreateStoreStaffCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 姓名。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 手机号。 + /// + public string Phone { get; init; } = string.Empty; + + /// + /// 邮箱。 + /// + public string? Email { get; init; } + + /// + /// 角色。 + /// + public StaffRoleType RoleType { get; init; } = StaffRoleType.FrontDesk; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreTableAreaCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreTableAreaCommand.cs new file mode 100644 index 0000000..60511b4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/CreateStoreTableAreaCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 创建桌台区域命令。 +/// +public sealed record CreateStoreTableAreaCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 区域描述。 + /// + public string? Description { get; init; } + + /// + /// 排序值。 + /// + public int SortOrder { get; init; } = 100; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreBusinessHourCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreBusinessHourCommand.cs new file mode 100644 index 0000000..d101680 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreBusinessHourCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除营业时段命令。 +/// +public sealed record DeleteStoreBusinessHourCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 营业时段 ID。 + /// + public long BusinessHourId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreDeliveryZoneCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreDeliveryZoneCommand.cs new file mode 100644 index 0000000..0e9e57d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreDeliveryZoneCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除配送区域命令。 +/// +public sealed record DeleteStoreDeliveryZoneCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 配送区域 ID。 + /// + public long DeliveryZoneId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreEmployeeShiftCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreEmployeeShiftCommand.cs new file mode 100644 index 0000000..0e75d12 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreEmployeeShiftCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除员工排班命令。 +/// +public sealed record DeleteStoreEmployeeShiftCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 排班 ID。 + /// + public long ShiftId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreHolidayCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreHolidayCommand.cs new file mode 100644 index 0000000..8e86eb8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreHolidayCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除节假日配置命令。 +/// +public sealed record DeleteStoreHolidayCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 节假日 ID。 + /// + public long HolidayId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStorePickupSlotCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStorePickupSlotCommand.cs new file mode 100644 index 0000000..e1a80f5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStorePickupSlotCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除自提档期命令。 +/// +public sealed record DeleteStorePickupSlotCommand : IRequest +{ + /// + /// 档期 ID。 + /// + public long SlotId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreStaffCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreStaffCommand.cs new file mode 100644 index 0000000..6c9c94b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreStaffCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除门店员工命令。 +/// +public sealed record DeleteStoreStaffCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 员工 ID。 + /// + public long StaffId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableAreaCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableAreaCommand.cs new file mode 100644 index 0000000..9f74c48 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableAreaCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除桌台区域命令。 +/// +public sealed record DeleteStoreTableAreaCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域 ID。 + /// + public long AreaId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableCommand.cs new file mode 100644 index 0000000..0480296 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/DeleteStoreTableCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 删除桌码命令。 +/// +public sealed record DeleteStoreTableCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 桌台 ID。 + /// + public long TableId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/GenerateStoreTablesCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/GenerateStoreTablesCommand.cs new file mode 100644 index 0000000..4c10d56 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/GenerateStoreTablesCommand.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 批量生成桌码命令。 +/// +public sealed record GenerateStoreTablesCommand : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 桌码前缀。 + /// + public string TableCodePrefix { get; init; } = "T"; + + /// + /// 起始序号。 + /// + public int StartNumber { get; init; } = 1; + + /// + /// 生成数量。 + /// + public int Count { get; init; } + + /// + /// 默认容量。 + /// + public int DefaultCapacity { get; init; } = 2; + + /// + /// 区域 ID。 + /// + public long? AreaId { get; init; } + + /// + /// 标签。 + /// + public string? Tags { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreBusinessHourCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreBusinessHourCommand.cs new file mode 100644 index 0000000..7c13e2e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreBusinessHourCommand.cs @@ -0,0 +1,51 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新营业时段命令。 +/// +public sealed record UpdateStoreBusinessHourCommand : IRequest +{ + /// + /// 营业时段 ID。 + /// + public long BusinessHourId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 星期几。 + /// + public DayOfWeek DayOfWeek { get; init; } + + /// + /// 时段类型。 + /// + public BusinessHourType HourType { get; init; } = BusinessHourType.Normal; + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 容量限制。 + /// + public int? CapacityLimit { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs index 63a178d..c7ef3b5 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreCommand.cs @@ -103,4 +103,14 @@ public sealed record UpdateStoreCommand : IRequest /// 支持配送。 /// public bool SupportsDelivery { get; init; } = true; + + /// + /// 支持预约。 + /// + public bool SupportsReservation { get; init; } + + /// + /// 支持排队叫号。 + /// + public bool SupportsQueueing { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreDeliveryZoneCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreDeliveryZoneCommand.cs new file mode 100644 index 0000000..e21ded5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreDeliveryZoneCommand.cs @@ -0,0 +1,50 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新配送区域命令。 +/// +public sealed record UpdateStoreDeliveryZoneCommand : IRequest +{ + /// + /// 配送区域 ID。 + /// + public long DeliveryZoneId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域名称。 + /// + public string ZoneName { get; init; } = string.Empty; + + /// + /// GeoJSON。 + /// + public string PolygonGeoJson { get; init; } = string.Empty; + + /// + /// 起送价。 + /// + public decimal? MinimumOrderAmount { get; init; } + + /// + /// 配送费。 + /// + public decimal? DeliveryFee { get; init; } + + /// + /// 预计分钟。 + /// + public int? EstimatedMinutes { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } = 100; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreEmployeeShiftCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreEmployeeShiftCommand.cs new file mode 100644 index 0000000..c1da376 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreEmployeeShiftCommand.cs @@ -0,0 +1,51 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新员工排班命令。 +/// +public sealed record UpdateStoreEmployeeShiftCommand : IRequest +{ + /// + /// 排班 ID。 + /// + public long ShiftId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 员工 ID。 + /// + public long StaffId { get; init; } + + /// + /// 班次日期。 + /// + public DateTime ShiftDate { get; init; } + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 排班角色。 + /// + public StaffRoleType RoleType { get; init; } = StaffRoleType.FrontDesk; + + /// + /// 备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreHolidayCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreHolidayCommand.cs new file mode 100644 index 0000000..c228bb1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreHolidayCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新节假日配置命令。 +/// +public sealed record UpdateStoreHolidayCommand : IRequest +{ + /// + /// 节假日 ID。 + /// + public long HolidayId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 日期。 + /// + public DateTime Date { get; init; } + + /// + /// 是否闭店。 + /// + public bool IsClosed { get; init; } = true; + + /// + /// 说明。 + /// + public string? Reason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStorePickupSlotCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStorePickupSlotCommand.cs new file mode 100644 index 0000000..9505407 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStorePickupSlotCommand.cs @@ -0,0 +1,55 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新自提档期命令。 +/// +public sealed record UpdateStorePickupSlotCommand : IRequest +{ + /// + /// 档期 ID。 + /// + public long SlotId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 截单分钟。 + /// + public int CutoffMinutes { get; init; } + + /// + /// 容量。 + /// + public int Capacity { get; init; } + + /// + /// 适用星期。 + /// + public string Weekdays { get; init; } = string.Empty; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreStaffCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreStaffCommand.cs new file mode 100644 index 0000000..335a9cf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreStaffCommand.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新门店员工命令。 +/// +public sealed record UpdateStoreStaffCommand : IRequest +{ + /// + /// 员工 ID。 + /// + public long StaffId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 姓名。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 手机号。 + /// + public string Phone { get; init; } = string.Empty; + + /// + /// 邮箱。 + /// + public string? Email { get; init; } + + /// + /// 角色。 + /// + public StaffRoleType RoleType { get; init; } = StaffRoleType.FrontDesk; + + /// + /// 状态。 + /// + public StaffStatus Status { get; init; } = StaffStatus.Active; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableAreaCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableAreaCommand.cs new file mode 100644 index 0000000..b113491 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableAreaCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新桌台区域命令。 +/// +public sealed record UpdateStoreTableAreaCommand : IRequest +{ + /// + /// 区域 ID。 + /// + public long AreaId { get; init; } + + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 区域描述。 + /// + public string? Description { get; init; } + + /// + /// 排序值。 + /// + public int SortOrder { get; init; } = 100; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableCommand.cs new file mode 100644 index 0000000..d93e92c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpdateStoreTableCommand.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 更新桌码命令。 +/// +public sealed record UpdateStoreTableCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 桌台 ID。 + /// + public long TableId { get; init; } + + /// + /// 区域 ID。 + /// + public long? AreaId { get; init; } + + /// + /// 桌码。 + /// + public string TableCode { get; init; } = string.Empty; + + /// + /// 容量。 + /// + public int Capacity { get; init; } + + /// + /// 标签。 + /// + public string? Tags { get; init; } + + /// + /// 状态。 + /// + public StoreTableStatus Status { get; init; } = StoreTableStatus.Idle; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpsertStorePickupSettingCommand.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpsertStorePickupSettingCommand.cs new file mode 100644 index 0000000..4822b8f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Commands/UpsertStorePickupSettingCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Commands; + +/// +/// 新增或更新自提配置命令。 +/// +public sealed record UpsertStorePickupSettingCommand : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 是否允许当天。 + /// + public bool AllowToday { get; init; } = true; + + /// + /// 可预约天数。 + /// + public int AllowDaysAhead { get; init; } = 3; + + /// + /// 默认截单分钟。 + /// + public int DefaultCutoffMinutes { get; init; } = 30; + + /// + /// 单笔最大份数。 + /// + public int? MaxQuantityPerOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreBusinessHourDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreBusinessHourDto.cs new file mode 100644 index 0000000..0a28ebd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreBusinessHourDto.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店营业时段 DTO。 +/// +public sealed record StoreBusinessHourDto +{ + /// + /// 营业时段 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 星期几。 + /// + public DayOfWeek DayOfWeek { get; init; } + + /// + /// 时段类型。 + /// + public BusinessHourType HourType { get; init; } + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 容量限制。 + /// + public int? CapacityLimit { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDeliveryZoneDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDeliveryZoneDto.cs new file mode 100644 index 0000000..588aa43 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDeliveryZoneDto.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店配送区域 DTO。 +/// +public sealed record StoreDeliveryZoneDto +{ + /// + /// 配送区域 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 区域名称。 + /// + public string ZoneName { get; init; } = string.Empty; + + /// + /// GeoJSON。 + /// + public string PolygonGeoJson { get; init; } = string.Empty; + + /// + /// 起送价。 + /// + public decimal? MinimumOrderAmount { get; init; } + + /// + /// 配送费。 + /// + public decimal? DeliveryFee { get; init; } + + /// + /// 预计分钟。 + /// + public int? EstimatedMinutes { get; init; } + + /// + /// 排序。 + /// + public int SortOrder { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs index 5412717..8cf3cc0 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreDto.cs @@ -112,6 +112,16 @@ public sealed class StoreDto /// public bool SupportsDelivery { get; init; } + /// + /// 支持预约。 + /// + public bool SupportsReservation { get; init; } + + /// + /// 支持排队叫号。 + /// + public bool SupportsQueueing { get; init; } + /// /// 创建时间。 /// diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreEmployeeShiftDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreEmployeeShiftDto.cs new file mode 100644 index 0000000..05c9866 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreEmployeeShiftDto.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 员工排班 DTO。 +/// +public sealed record StoreEmployeeShiftDto +{ + /// + /// 排班 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 员工 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StaffId { get; init; } + + /// + /// 班次日期。 + /// + public DateTime ShiftDate { get; init; } + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 排班角色。 + /// + public StaffRoleType RoleType { get; init; } + + /// + /// 备注。 + /// + public string? Notes { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreHolidayDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreHolidayDto.cs new file mode 100644 index 0000000..a861b85 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreHolidayDto.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店节假日 DTO。 +/// +public sealed record StoreHolidayDto +{ + /// + /// 节假日 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 日期。 + /// + public DateTime Date { get; init; } + + /// + /// 是否闭店。 + /// + public bool IsClosed { get; init; } + + /// + /// 说明。 + /// + public string? Reason { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSettingDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSettingDto.cs new file mode 100644 index 0000000..5b071cd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSettingDto.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 自提配置 DTO。 +/// +public sealed record StorePickupSettingDto +{ + /// + /// 配置 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 是否允许当天自提。 + /// + public bool AllowToday { get; init; } + + /// + /// 可预约天数。 + /// + public int AllowDaysAhead { get; init; } + + /// + /// 默认截单分钟。 + /// + public int DefaultCutoffMinutes { get; init; } + + /// + /// 单笔最大自提份数。 + /// + public int? MaxQuantityPerOrder { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSlotDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSlotDto.cs new file mode 100644 index 0000000..5dc36ce --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StorePickupSlotDto.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 自提档期 DTO。 +/// +public sealed record StorePickupSlotDto +{ + /// + /// 档期 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 开始时间。 + /// + public TimeSpan StartTime { get; init; } + + /// + /// 结束时间。 + /// + public TimeSpan EndTime { get; init; } + + /// + /// 截单分钟。 + /// + public int CutoffMinutes { get; init; } + + /// + /// 容量。 + /// + public int Capacity { get; init; } + + /// + /// 已占用。 + /// + public int ReservedCount { get; init; } + + /// + /// 适用星期(1-7)。 + /// + public string Weekdays { get; init; } = string.Empty; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreStaffDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreStaffDto.cs new file mode 100644 index 0000000..a11cbc7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreStaffDto.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 门店员工 DTO。 +/// +public sealed record StoreStaffDto +{ + /// + /// 员工 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 商户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long MerchantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? StoreId { get; init; } + + /// + /// 姓名。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 手机号。 + /// + public string Phone { get; init; } = string.Empty; + + /// + /// 邮箱。 + /// + public string? Email { get; init; } + + /// + /// 角色类型。 + /// + public StaffRoleType RoleType { get; init; } + + /// + /// 状态。 + /// + public StaffStatus Status { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableAreaDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableAreaDto.cs new file mode 100644 index 0000000..8c5e533 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableAreaDto.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 桌台区域 DTO。 +/// +public sealed record StoreTableAreaDto +{ + /// + /// 区域 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 区域名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 区域描述。 + /// + public string? Description { get; init; } + + /// + /// 排序值。 + /// + public int SortOrder { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableContextDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableContextDto.cs new file mode 100644 index 0000000..4ce0e86 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableContextDto.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 桌码上下文 DTO。 +/// +public sealed record StoreTableContextDto +{ + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 门店名称。 + /// + public string StoreName { get; init; } = string.Empty; + + /// + /// 门店公告。 + /// + public string? Announcement { get; init; } + + /// + /// 门店标签。 + /// + public string? Tags { get; init; } + + /// + /// 桌台 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TableId { get; init; } + + /// + /// 桌码。 + /// + public string TableCode { get; init; } = string.Empty; + + /// + /// 区域 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? AreaId { get; init; } + + /// + /// 容量。 + /// + public int Capacity { get; init; } + + /// + /// 标签。 + /// + public string? TableTags { get; init; } + + /// + /// 状态。 + /// + public StoreTableStatus Status { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableDto.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableDto.cs new file mode 100644 index 0000000..b66b60c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableDto.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Stores.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 桌台 DTO。 +/// +public sealed record StoreTableDto +{ + /// + /// 桌台 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 门店 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long StoreId { get; init; } + + /// + /// 区域 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? AreaId { get; init; } + + /// + /// 桌码。 + /// + public string TableCode { get; init; } = string.Empty; + + /// + /// 容量。 + /// + public int Capacity { get; init; } + + /// + /// 标签。 + /// + public string? Tags { get; init; } + + /// + /// 状态。 + /// + public StoreTableStatus Status { get; init; } + + /// + /// 二维码地址。 + /// + public string? QrCodeUrl { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableExportResult.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableExportResult.cs new file mode 100644 index 0000000..fea6b4e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Dto/StoreTableExportResult.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.App.Stores.Dto; + +/// +/// 桌台二维码导出结果。 +/// +public sealed record StoreTableExportResult +{ + /// + /// 文件名。 + /// + public string FileName { get; init; } = string.Empty; + + /// + /// 内容类型。 + /// + public string ContentType { get; init; } = string.Empty; + + /// + /// 文件内容。 + /// + public byte[] Content { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreBusinessHourCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreBusinessHourCommandHandler.cs new file mode 100644 index 0000000..9b474b3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreBusinessHourCommandHandler.cs @@ -0,0 +1,57 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建营业时段处理器。 +/// +public sealed class CreateStoreBusinessHourCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreateStoreBusinessHourCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var store = await _storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 构建实体 + var hour = new StoreBusinessHour + { + StoreId = request.StoreId, + DayOfWeek = request.DayOfWeek, + HourType = request.HourType, + StartTime = request.StartTime, + EndTime = request.EndTime, + CapacityLimit = request.CapacityLimit, + Notes = request.Notes?.Trim() + }; + + // 3. 持久化 + await _storeRepository.AddBusinessHoursAsync(new[] { hour }, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("创建营业时段 {BusinessHourId} 对应门店 {StoreId}", hour.Id, request.StoreId); + + // 4. 返回 DTO + return StoreMapping.ToDto(hour); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs index f254d6f..104c302 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreCommandHandler.cs @@ -39,7 +39,9 @@ public sealed class CreateStoreCommandHandler(IStoreRepository storeRepository, DeliveryRadiusKm = request.DeliveryRadiusKm, SupportsDineIn = request.SupportsDineIn, SupportsPickup = request.SupportsPickup, - SupportsDelivery = request.SupportsDelivery + SupportsDelivery = request.SupportsDelivery, + SupportsReservation = request.SupportsReservation, + SupportsQueueing = request.SupportsQueueing }; // 2. 持久化 @@ -73,6 +75,8 @@ public sealed class CreateStoreCommandHandler(IStoreRepository storeRepository, SupportsDineIn = store.SupportsDineIn, SupportsPickup = store.SupportsPickup, SupportsDelivery = store.SupportsDelivery, + SupportsReservation = store.SupportsReservation, + SupportsQueueing = store.SupportsQueueing, CreatedAt = store.CreatedAt }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreDeliveryZoneCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreDeliveryZoneCommandHandler.cs new file mode 100644 index 0000000..c5858b3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreDeliveryZoneCommandHandler.cs @@ -0,0 +1,57 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建配送区域处理器。 +/// +public sealed class CreateStoreDeliveryZoneCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreateStoreDeliveryZoneCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var store = await _storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 构建实体 + var zone = new StoreDeliveryZone + { + StoreId = request.StoreId, + ZoneName = request.ZoneName.Trim(), + PolygonGeoJson = request.PolygonGeoJson.Trim(), + MinimumOrderAmount = request.MinimumOrderAmount, + DeliveryFee = request.DeliveryFee, + EstimatedMinutes = request.EstimatedMinutes, + SortOrder = request.SortOrder + }; + + // 3. 持久化 + await _storeRepository.AddDeliveryZonesAsync(new[] { zone }, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("创建配送区域 {DeliveryZoneId} 对应门店 {StoreId}", zone.Id, request.StoreId); + + // 4. 返回 DTO + return StoreMapping.ToDto(zone); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreEmployeeShiftCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreEmployeeShiftCommandHandler.cs new file mode 100644 index 0000000..b47af84 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreEmployeeShiftCommandHandler.cs @@ -0,0 +1,72 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建排班处理器。 +/// +public sealed class CreateStoreEmployeeShiftCommandHandler( + IStoreRepository storeRepository, + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(CreateStoreEmployeeShiftCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 校验员工归属与状态 + var staff = await merchantRepository.FindStaffByIdAsync(request.StaffId, tenantId, cancellationToken); + if (staff is null || (staff.StoreId.HasValue && staff.StoreId != request.StoreId)) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "员工不存在或不属于该门店"); + } + + // 3. 校验日期与冲突 + var from = request.ShiftDate.Date; + var to = request.ShiftDate.Date; + var shifts = await storeRepository.GetShiftsAsync(request.StoreId, tenantId, from, to, cancellationToken); + var hasConflict = shifts.Any(x => x.StaffId == request.StaffId && x.ShiftDate == request.ShiftDate); + if (hasConflict) + { + throw new BusinessException(ErrorCodes.Conflict, "该员工当日已存在排班"); + } + + // 4. 构建实体 + var shift = new StoreEmployeeShift + { + StoreId = request.StoreId, + StaffId = request.StaffId, + ShiftDate = request.ShiftDate.Date, + StartTime = request.StartTime, + EndTime = request.EndTime, + RoleType = request.RoleType, + Notes = request.Notes?.Trim() + }; + + // 5. 持久化 + await storeRepository.AddShiftsAsync(new[] { shift }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("创建排班 {ShiftId} 员工 {StaffId} 门店 {StoreId}", shift.Id, shift.StaffId, shift.StoreId); + + // 6. 返回 DTO + return StoreMapping.ToDto(shift); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreHolidayCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreHolidayCommandHandler.cs new file mode 100644 index 0000000..f5f5692 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreHolidayCommandHandler.cs @@ -0,0 +1,54 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建节假日配置处理器。 +/// +public sealed class CreateStoreHolidayCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(CreateStoreHolidayCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var store = await _storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 构建实体 + var holiday = new StoreHoliday + { + StoreId = request.StoreId, + Date = request.Date, + IsClosed = request.IsClosed, + Reason = request.Reason?.Trim() + }; + + // 3. 持久化 + await _storeRepository.AddHolidaysAsync(new[] { holiday }, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("创建节假日 {HolidayId} 对应门店 {StoreId}", holiday.Id, request.StoreId); + + // 4. 返回 DTO + return StoreMapping.ToDto(holiday); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStorePickupSlotCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStorePickupSlotCommandHandler.cs new file mode 100644 index 0000000..9454424 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStorePickupSlotCommandHandler.cs @@ -0,0 +1,64 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建自提档期处理器。 +/// +public sealed class CreateStorePickupSlotCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(CreateStorePickupSlotCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 新建档期 + var slot = new StorePickupSlot + { + TenantId = tenantId, + StoreId = request.StoreId, + Name = request.Name.Trim(), + StartTime = request.StartTime, + EndTime = request.EndTime, + CutoffMinutes = request.CutoffMinutes, + Capacity = request.Capacity, + ReservedCount = 0, + Weekdays = request.Weekdays, + IsEnabled = request.IsEnabled + }; + await storeRepository.AddPickupSlotsAsync(new[] { slot }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("创建自提档期 {SlotId} for store {StoreId}", slot.Id, request.StoreId); + return new StorePickupSlotDto + { + Id = slot.Id, + StoreId = slot.StoreId, + Name = slot.Name, + StartTime = slot.StartTime, + EndTime = slot.EndTime, + CutoffMinutes = slot.CutoffMinutes, + Capacity = slot.Capacity, + ReservedCount = slot.ReservedCount, + Weekdays = slot.Weekdays, + IsEnabled = slot.IsEnabled + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreStaffCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreStaffCommandHandler.cs new file mode 100644 index 0000000..5ae4974 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreStaffCommandHandler.cs @@ -0,0 +1,56 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建门店员工处理器。 +/// +public sealed class CreateStoreStaffCommandHandler( + IMerchantRepository merchantRepository, + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(CreateStoreStaffCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 组装员工 + var staff = new MerchantStaff + { + MerchantId = store.MerchantId, + StoreId = request.StoreId, + Name = request.Name.Trim(), + Phone = request.Phone.Trim(), + Email = request.Email?.Trim(), + RoleType = request.RoleType, + Status = StaffStatus.Active + }; + + // 3. 持久化 + await merchantRepository.AddStaffAsync(staff, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("创建门店员工 {StaffId} 门店 {StoreId}", staff.Id, request.StoreId); + + // 4. 返回 DTO + return StoreMapping.ToDto(staff); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreTableAreaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreTableAreaCommandHandler.cs new file mode 100644 index 0000000..4999f4f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/CreateStoreTableAreaCommandHandler.cs @@ -0,0 +1,58 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 创建桌台区域处理器。 +/// +public sealed class CreateStoreTableAreaCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(CreateStoreTableAreaCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 校验区域名称唯一 + var existingAreas = await storeRepository.GetTableAreasAsync(request.StoreId, tenantId, cancellationToken); + var hasDuplicate = existingAreas.Any(x => x.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)); + if (hasDuplicate) + { + throw new BusinessException(ErrorCodes.Conflict, "区域名称已存在"); + } + + // 3. 构建实体 + var area = new StoreTableArea + { + StoreId = request.StoreId, + Name = request.Name.Trim(), + Description = request.Description?.Trim(), + SortOrder = request.SortOrder + }; + + // 4. 持久化 + await storeRepository.AddTableAreasAsync(new[] { area }, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("创建桌台区域 {AreaId} 对应门店 {StoreId}", area.Id, request.StoreId); + + // 5. 返回 DTO + return StoreMapping.ToDto(area); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreBusinessHourCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreBusinessHourCommandHandler.cs new file mode 100644 index 0000000..816cd88 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreBusinessHourCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除营业时段处理器。 +/// +public sealed class DeleteStoreBusinessHourCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeleteStoreBusinessHourCommand request, CancellationToken cancellationToken) + { + // 1. 读取时段 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindBusinessHourByIdAsync(request.BusinessHourId, tenantId, cancellationToken); + if (existing is null) + { + return false; + } + + // 2. 校验门店归属 + if (existing.StoreId != request.StoreId) + { + return false; + } + + // 3. 删除 + await _storeRepository.DeleteBusinessHourAsync(request.BusinessHourId, tenantId, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除营业时段 {BusinessHourId} 对应门店 {StoreId}", request.BusinessHourId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreDeliveryZoneCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreDeliveryZoneCommandHandler.cs new file mode 100644 index 0000000..c7e7abb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreDeliveryZoneCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除配送区域处理器。 +/// +public sealed class DeleteStoreDeliveryZoneCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeleteStoreDeliveryZoneCommand request, CancellationToken cancellationToken) + { + // 1. 读取区域 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindDeliveryZoneByIdAsync(request.DeliveryZoneId, tenantId, cancellationToken); + if (existing is null) + { + return false; + } + + // 2. 校验门店归属 + if (existing.StoreId != request.StoreId) + { + return false; + } + + // 3. 删除 + await _storeRepository.DeleteDeliveryZoneAsync(request.DeliveryZoneId, tenantId, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除配送区域 {DeliveryZoneId} 对应门店 {StoreId}", request.DeliveryZoneId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreEmployeeShiftCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreEmployeeShiftCommandHandler.cs new file mode 100644 index 0000000..d29a555 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreEmployeeShiftCommandHandler.cs @@ -0,0 +1,36 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除排班处理器。 +/// +public sealed class DeleteStoreEmployeeShiftCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeleteStoreEmployeeShiftCommand request, CancellationToken cancellationToken) + { + // 1. 读取排班 + var tenantId = tenantProvider.GetCurrentTenantId(); + var shift = await storeRepository.FindShiftByIdAsync(request.ShiftId, tenantId, cancellationToken); + if (shift is null || shift.StoreId != request.StoreId) + { + return false; + } + + // 2. 删除 + await storeRepository.DeleteShiftAsync(request.ShiftId, tenantId, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除排班 {ShiftId} 门店 {StoreId}", request.ShiftId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreHolidayCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreHolidayCommandHandler.cs new file mode 100644 index 0000000..a262ffe --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreHolidayCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除节假日配置处理器。 +/// +public sealed class DeleteStoreHolidayCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(DeleteStoreHolidayCommand request, CancellationToken cancellationToken) + { + // 1. 读取配置 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindHolidayByIdAsync(request.HolidayId, tenantId, cancellationToken); + if (existing is null) + { + return false; + } + + // 2. 校验门店归属 + if (existing.StoreId != request.StoreId) + { + return false; + } + + // 3. 删除 + await _storeRepository.DeleteHolidayAsync(request.HolidayId, tenantId, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("删除节假日 {HolidayId} 对应门店 {StoreId}", request.HolidayId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStorePickupSlotCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStorePickupSlotCommandHandler.cs new file mode 100644 index 0000000..c42e4d0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStorePickupSlotCommandHandler.cs @@ -0,0 +1,28 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除自提档期处理器。 +/// +public sealed class DeleteStorePickupSlotCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeleteStorePickupSlotCommand request, CancellationToken cancellationToken) + { + // 1. 删除档期 + var tenantId = tenantProvider.GetCurrentTenantId(); + await storeRepository.DeletePickupSlotAsync(request.SlotId, tenantId, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除自提档期 {SlotId}", request.SlotId); + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreStaffCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreStaffCommandHandler.cs new file mode 100644 index 0000000..5d438f8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreStaffCommandHandler.cs @@ -0,0 +1,35 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除门店员工处理器。 +/// +public sealed class DeleteStoreStaffCommandHandler( + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeleteStoreStaffCommand request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var staff = await merchantRepository.FindStaffByIdAsync(request.StaffId, tenantId, cancellationToken); + if (staff is null || staff.StoreId != request.StoreId) + { + return false; + } + + // 逻辑删除未定义,直接物理删除 + await merchantRepository.DeleteStaffAsync(staff.Id, tenantId, cancellationToken); + await merchantRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除门店员工 {StaffId} 门店 {StoreId}", request.StaffId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableAreaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableAreaCommandHandler.cs new file mode 100644 index 0000000..48dad11 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableAreaCommandHandler.cs @@ -0,0 +1,52 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除桌台区域处理器。 +/// +public sealed class DeleteStoreTableAreaCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeleteStoreTableAreaCommand request, CancellationToken cancellationToken) + { + // 1. 读取区域 + var tenantId = tenantProvider.GetCurrentTenantId(); + var area = await storeRepository.FindTableAreaByIdAsync(request.AreaId, tenantId, cancellationToken); + if (area is null) + { + return false; + } + + // 2. 校验门店归属 + if (area.StoreId != request.StoreId) + { + return false; + } + + // 3. 校验区域下无桌码 + var tables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken); + var hasTable = tables.Any(x => x.AreaId == request.AreaId); + if (hasTable) + { + throw new BusinessException(ErrorCodes.Conflict, "区域下仍有桌码,无法删除"); + } + + // 4. 删除 + await storeRepository.DeleteTableAreaAsync(request.AreaId, tenantId, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除桌台区域 {AreaId} 对应门店 {StoreId}", request.AreaId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableCommandHandler.cs new file mode 100644 index 0000000..9828253 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/DeleteStoreTableCommandHandler.cs @@ -0,0 +1,36 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 删除桌码处理器。 +/// +public sealed class DeleteStoreTableCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(DeleteStoreTableCommand request, CancellationToken cancellationToken) + { + // 1. 读取桌码 + var tenantId = tenantProvider.GetCurrentTenantId(); + var table = await storeRepository.FindTableByIdAsync(request.TableId, tenantId, cancellationToken); + if (table is null || table.StoreId != request.StoreId) + { + return false; + } + + // 2. 删除 + await storeRepository.DeleteTableAsync(request.TableId, tenantId, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("删除桌码 {TableId} 对应门店 {StoreId}", request.TableId, request.StoreId); + + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ExportStoreTableQRCodesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ExportStoreTableQRCodesQueryHandler.cs new file mode 100644 index 0000000..95ead70 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ExportStoreTableQRCodesQueryHandler.cs @@ -0,0 +1,86 @@ +using System.IO.Compression; +using System.Linq; +using System.Text; +using MediatR; +using Microsoft.Extensions.Logging; +using QRCoder; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 导出桌码二维码处理器。 +/// +public sealed class ExportStoreTableQRCodesQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(ExportStoreTableQRCodesQuery request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + return null; + } + + // 2. 获取桌码列表 + var tables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken); + if (request.AreaId.HasValue) + { + tables = tables.Where(x => x.AreaId == request.AreaId.Value).ToList(); + } + + if (tables.Count == 0) + { + return null; + } + + // 3. 生成 ZIP + var template = string.IsNullOrWhiteSpace(request.QrContentTemplate) ? "{code}" : request.QrContentTemplate!; + using var memoryStream = new MemoryStream(); + using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true, Encoding.UTF8)) + { + foreach (var table in tables) + { + var content = BuildPayload(template, table.TableCode); + var svg = RenderSvg(content); + var entry = archive.CreateEntry($"{table.TableCode}.svg", CompressionLevel.Fastest); + using var entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream, Encoding.UTF8); + writer.Write(svg); + } + } + + // 4. 返回导出结果 + var fileName = $"store_{request.StoreId}_tables_{DateTime.UtcNow:yyyyMMddHHmmss}.zip"; + logger.LogInformation("导出门店 {StoreId} 桌码二维码 {Count} 个", request.StoreId, tables.Count); + return new StoreTableExportResult + { + FileName = fileName, + ContentType = "application/zip", + Content = memoryStream.ToArray() + }; + } + + private static string BuildPayload(string template, string tableCode) + { + var payload = template.Replace("{code}", tableCode, StringComparison.OrdinalIgnoreCase); + return string.IsNullOrWhiteSpace(payload) ? tableCode : payload; + } + + private static string RenderSvg(string payload) + { + using var generator = new QRCodeGenerator(); + var data = generator.CreateQrCode(payload, QRCodeGenerator.ECCLevel.Q); + var svg = new SvgQRCode(data); + return svg.GetGraphic(5); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GenerateStoreTablesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GenerateStoreTablesCommandHandler.cs new file mode 100644 index 0000000..ad2cf70 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GenerateStoreTablesCommandHandler.cs @@ -0,0 +1,73 @@ +using System.Linq; +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 批量生成桌码处理器。 +/// +public sealed class GenerateStoreTablesCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler> +{ + /// + public async Task> Handle(GenerateStoreTablesCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 校验区域归属 + if (request.AreaId.HasValue) + { + var area = await storeRepository.FindTableAreaByIdAsync(request.AreaId.Value, tenantId, cancellationToken); + if (area is null || area.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "桌台区域不存在或不属于该门店"); + } + } + + // 3. 校验桌码唯一性 + var existingTables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken); + var newCodes = Enumerable.Range(request.StartNumber, request.Count) + .Select(i => $"{request.TableCodePrefix.Trim()}{i}") + .ToList(); + var conflicts = existingTables.Where(t => newCodes.Contains(t.TableCode, StringComparer.OrdinalIgnoreCase)).ToList(); + if (conflicts.Count > 0) + { + throw new BusinessException(ErrorCodes.Conflict, "桌码已存在,生成失败"); + } + + // 4. 构建实体 + var tables = newCodes.Select(code => new StoreTable + { + StoreId = request.StoreId, + AreaId = request.AreaId, + TableCode = code, + Capacity = request.DefaultCapacity, + Tags = request.Tags?.Trim() + }).ToList(); + + // 5. 持久化 + await storeRepository.AddTablesAsync(tables, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("批量创建桌码 {Count} 条 对应门店 {StoreId}", tables.Count, request.StoreId); + + // 6. 返回 DTO + return tables.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetAvailablePickupSlotsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetAvailablePickupSlotsQueryHandler.cs new file mode 100644 index 0000000..bbfb8f7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetAvailablePickupSlotsQueryHandler.cs @@ -0,0 +1,83 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 可用自提档期查询处理器。 +/// +public sealed class GetAvailablePickupSlotsQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(GetAvailablePickupSlotsQuery request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var date = request.Date.Date; + // 1. 读取配置 + var setting = await storeRepository.GetPickupSettingAsync(request.StoreId, tenantId, cancellationToken); + var allowDays = setting?.AllowDaysAhead ?? 0; + var allowToday = setting?.AllowToday ?? false; + var defaultCutoff = setting?.DefaultCutoffMinutes ?? 30; + + // 2. 校验日期范围 + if (!allowToday && date == DateTime.UtcNow.Date) + { + return []; + } + + if (date > DateTime.UtcNow.Date.AddDays(allowDays)) + { + return []; + } + + // 3. 读取档期 + var slots = await storeRepository.GetPickupSlotsAsync(request.StoreId, tenantId, cancellationToken); + var weekday = (int)date.DayOfWeek; + weekday = weekday == 0 ? 7 : weekday; + var nowUtc = DateTime.UtcNow; + + // 4. 过滤可用 + var available = slots + .Where(x => x.IsEnabled && ContainsDay(x.Weekdays, weekday)) + .Select(slot => + { + var cutoff = slot.CutoffMinutes == 0 ? defaultCutoff : slot.CutoffMinutes; + var slotStartUtc = date.Add(slot.StartTime); + // 判断截单 + var cutoffTime = slotStartUtc.AddMinutes(-cutoff); + var isCutoff = nowUtc > cutoffTime; + var remaining = slot.Capacity - slot.ReservedCount; + return (slot, isCutoff, remaining); + }) + .Where(x => !x.isCutoff && x.remaining > 0) + .Select(x => new StorePickupSlotDto + { + Id = x.slot.Id, + StoreId = x.slot.StoreId, + Name = x.slot.Name, + StartTime = x.slot.StartTime, + EndTime = x.slot.EndTime, + CutoffMinutes = x.slot.CutoffMinutes, + Capacity = x.slot.Capacity, + ReservedCount = x.slot.ReservedCount, + Weekdays = x.slot.Weekdays, + IsEnabled = x.slot.IsEnabled + }) + .ToList(); + + return available; + } + + private static bool ContainsDay(string weekdays, int target) + { + // 解析适用星期 + var parts = weekdays.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return parts.Any(p => int.TryParse(p, out var val) && val == target); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs index 995ddde..ecd7415 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreByIdQueryHandler.cs @@ -48,6 +48,8 @@ public sealed class GetStoreByIdQueryHandler( SupportsDineIn = store.SupportsDineIn, SupportsPickup = store.SupportsPickup, SupportsDelivery = store.SupportsDelivery, + SupportsReservation = store.SupportsReservation, + SupportsQueueing = store.SupportsQueueing, CreatedAt = store.CreatedAt }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStorePickupSettingQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStorePickupSettingQueryHandler.cs new file mode 100644 index 0000000..f7899e3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStorePickupSettingQueryHandler.cs @@ -0,0 +1,37 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 获取自提配置处理器。 +/// +public sealed class GetStorePickupSettingQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(GetStorePickupSettingQuery request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var setting = await storeRepository.GetPickupSettingAsync(request.StoreId, tenantId, cancellationToken); + if (setting is null) + { + return null; + } + + return new StorePickupSettingDto + { + Id = setting.Id, + StoreId = setting.StoreId, + AllowToday = setting.AllowToday, + AllowDaysAhead = setting.AllowDaysAhead, + DefaultCutoffMinutes = setting.DefaultCutoffMinutes, + MaxQuantityPerOrder = setting.MaxQuantityPerOrder + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreTableContextQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreTableContextQueryHandler.cs new file mode 100644 index 0000000..8ef1bea --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/GetStoreTableContextQueryHandler.cs @@ -0,0 +1,55 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 桌码上下文查询处理器。 +/// +public sealed class GetStoreTableContextQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(GetStoreTableContextQuery request, CancellationToken cancellationToken) + { + // 1. 查询桌码 + var tenantId = tenantProvider.GetCurrentTenantId(); + var table = await storeRepository.FindTableByCodeAsync(request.TableCode, tenantId, cancellationToken); + if (table is null) + { + logger.LogWarning("未找到桌码 {TableCode}", request.TableCode); + return null; + } + + // 2. 查询门店 + var store = await storeRepository.FindByIdAsync(table.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 3. 组装上下文 + return new StoreTableContextDto + { + StoreId = store.Id, + StoreName = store.Name, + Announcement = store.Announcement, + Tags = store.Tags, + TableId = table.Id, + TableCode = table.TableCode, + AreaId = table.AreaId, + Capacity = table.Capacity, + TableTags = table.Tags, + Status = table.Status + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreBusinessHoursQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreBusinessHoursQueryHandler.cs new file mode 100644 index 0000000..f0f2c5d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreBusinessHoursQueryHandler.cs @@ -0,0 +1,31 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 营业时段列表查询处理器。 +/// +public sealed class ListStoreBusinessHoursQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(ListStoreBusinessHoursQuery request, CancellationToken cancellationToken) + { + // 1. 查询时段列表 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var hours = await _storeRepository.GetBusinessHoursAsync(request.StoreId, tenantId, cancellationToken); + + // 2. 映射 DTO + return hours.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreDeliveryZonesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreDeliveryZonesQueryHandler.cs new file mode 100644 index 0000000..ffcc741 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreDeliveryZonesQueryHandler.cs @@ -0,0 +1,31 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 配送区域列表查询处理器。 +/// +public sealed class ListStoreDeliveryZonesQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(ListStoreDeliveryZonesQuery request, CancellationToken cancellationToken) + { + // 1. 查询配送区域 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var zones = await _storeRepository.GetDeliveryZonesAsync(request.StoreId, tenantId, cancellationToken); + + // 2. 映射 DTO + return zones.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreEmployeeShiftsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreEmployeeShiftsQueryHandler.cs new file mode 100644 index 0000000..2dde5dd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreEmployeeShiftsQueryHandler.cs @@ -0,0 +1,37 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 排班列表查询处理器。 +/// +public sealed class ListStoreEmployeeShiftsQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(ListStoreEmployeeShiftsQuery request, CancellationToken cancellationToken) + { + // 1. 时间范围 + var from = request.From ?? DateTime.UtcNow.Date; + var to = request.To ?? from.AddDays(7); + var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 查询排班 + var shifts = await storeRepository.GetShiftsAsync(request.StoreId, tenantId, from, to, cancellationToken); + + if (request.StaffId.HasValue) + { + shifts = shifts.Where(x => x.StaffId == request.StaffId.Value).ToList(); + } + + // 3. 映射 DTO + return shifts.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreHolidaysQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreHolidaysQueryHandler.cs new file mode 100644 index 0000000..558b8f2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreHolidaysQueryHandler.cs @@ -0,0 +1,31 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 门店节假日列表查询处理器。 +/// +public sealed class ListStoreHolidaysQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + + /// + public async Task> Handle(ListStoreHolidaysQuery request, CancellationToken cancellationToken) + { + // 1. 查询节假日 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var holidays = await _storeRepository.GetHolidaysAsync(request.StoreId, tenantId, cancellationToken); + + // 2. 映射 DTO + return holidays.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStorePickupSlotsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStorePickupSlotsQueryHandler.cs new file mode 100644 index 0000000..146a7a4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStorePickupSlotsQueryHandler.cs @@ -0,0 +1,38 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 自提档期列表查询处理器。 +/// +public sealed class ListStorePickupSlotsQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(ListStorePickupSlotsQuery request, CancellationToken cancellationToken) + { + var tenantId = tenantProvider.GetCurrentTenantId(); + var slots = await storeRepository.GetPickupSlotsAsync(request.StoreId, tenantId, cancellationToken); + return slots + .Select(x => new StorePickupSlotDto + { + Id = x.Id, + StoreId = x.StoreId, + Name = x.Name, + StartTime = x.StartTime, + EndTime = x.EndTime, + CutoffMinutes = x.CutoffMinutes, + Capacity = x.Capacity, + ReservedCount = x.ReservedCount, + Weekdays = x.Weekdays, + IsEnabled = x.IsEnabled + }) + .ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreStaffQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreStaffQueryHandler.cs new file mode 100644 index 0000000..5a2e770 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreStaffQueryHandler.cs @@ -0,0 +1,47 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 门店员工列表查询处理器。 +/// +public sealed class ListStoreStaffQueryHandler( + IMerchantRepository merchantRepository, + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(ListStoreStaffQuery request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + return []; + } + + // 2. 查询员工 + var staffs = await merchantRepository.GetStaffByStoreAsync(request.StoreId, tenantId, cancellationToken); + + if (request.RoleType.HasValue) + { + staffs = staffs.Where(x => x.RoleType == request.RoleType.Value).ToList(); + } + + if (request.Status.HasValue) + { + staffs = staffs.Where(x => x.Status == request.Status.Value).ToList(); + } + + // 3. 映射 DTO + return staffs.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTableAreasQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTableAreasQueryHandler.cs new file mode 100644 index 0000000..5de8fa5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTableAreasQueryHandler.cs @@ -0,0 +1,26 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 桌台区域列表查询处理器。 +/// +public sealed class ListStoreTableAreasQueryHandler(IStoreRepository storeRepository, ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(ListStoreTableAreasQuery request, CancellationToken cancellationToken) + { + // 1. 查询区域列表 + var tenantId = tenantProvider.GetCurrentTenantId(); + var areas = await storeRepository.GetTableAreasAsync(request.StoreId, tenantId, cancellationToken); + + // 2. 映射 DTO + return areas.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTablesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTablesQueryHandler.cs new file mode 100644 index 0000000..786b67f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/ListStoreTablesQueryHandler.cs @@ -0,0 +1,39 @@ +using System.Linq; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Application.App.Stores.Queries; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 桌码列表查询处理器。 +/// +public sealed class ListStoreTablesQueryHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider) + : IRequestHandler> +{ + /// + public async Task> Handle(ListStoreTablesQuery request, CancellationToken cancellationToken) + { + // 1. 查询桌码列表 + var tenantId = tenantProvider.GetCurrentTenantId(); + var tables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken); + + // 2. 过滤 + if (request.AreaId.HasValue) + { + tables = tables.Where(x => x.AreaId == request.AreaId.Value).ToList(); + } + + if (request.Status.HasValue) + { + tables = tables.Where(x => x.Status == request.Status.Value).ToList(); + } + + // 3. 映射 DTO + return tables.Select(StoreMapping.ToDto).ToList(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs index 1bb609d..f9b2330 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/SearchStoresQueryHandler.cs @@ -75,6 +75,8 @@ public sealed class SearchStoresQueryHandler( SupportsDineIn = store.SupportsDineIn, SupportsPickup = store.SupportsPickup, SupportsDelivery = store.SupportsDelivery, + SupportsReservation = store.SupportsReservation, + SupportsQueueing = store.SupportsQueueing, CreatedAt = store.CreatedAt }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreBusinessHourCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreBusinessHourCommandHandler.cs new file mode 100644 index 0000000..b85bd55 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreBusinessHourCommandHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新营业时段处理器。 +/// +public sealed class UpdateStoreBusinessHourCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdateStoreBusinessHourCommand request, CancellationToken cancellationToken) + { + // 1. 读取时段 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindBusinessHourByIdAsync(request.BusinessHourId, tenantId, cancellationToken); + if (existing is null) + { + return null; + } + + // 2. 校验门店归属 + if (existing.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "营业时段不属于该门店"); + } + + // 3. 更新字段 + existing.DayOfWeek = request.DayOfWeek; + existing.HourType = request.HourType; + existing.StartTime = request.StartTime; + existing.EndTime = request.EndTime; + existing.CapacityLimit = request.CapacityLimit; + existing.Notes = request.Notes?.Trim(); + + // 4. 持久化 + await _storeRepository.UpdateBusinessHourAsync(existing, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("更新营业时段 {BusinessHourId} 对应门店 {StoreId}", existing.Id, existing.StoreId); + + // 5. 返回 DTO + return StoreMapping.ToDto(existing); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs index 934bb54..025ae4d 100644 --- a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreCommandHandler.cs @@ -51,6 +51,8 @@ public sealed class UpdateStoreCommandHandler( existing.SupportsDineIn = request.SupportsDineIn; existing.SupportsPickup = request.SupportsPickup; existing.SupportsDelivery = request.SupportsDelivery; + existing.SupportsReservation = request.SupportsReservation; + existing.SupportsQueueing = request.SupportsQueueing; // 3. 持久化 await _storeRepository.UpdateStoreAsync(existing, cancellationToken); @@ -83,6 +85,8 @@ public sealed class UpdateStoreCommandHandler( SupportsDineIn = store.SupportsDineIn, SupportsPickup = store.SupportsPickup, SupportsDelivery = store.SupportsDelivery, + SupportsReservation = store.SupportsReservation, + SupportsQueueing = store.SupportsQueueing, CreatedAt = store.CreatedAt }; } diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreDeliveryZoneCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreDeliveryZoneCommandHandler.cs new file mode 100644 index 0000000..ecf7f66 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreDeliveryZoneCommandHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新配送区域处理器。 +/// +public sealed class UpdateStoreDeliveryZoneCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdateStoreDeliveryZoneCommand request, CancellationToken cancellationToken) + { + // 1. 读取区域 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindDeliveryZoneByIdAsync(request.DeliveryZoneId, tenantId, cancellationToken); + if (existing is null) + { + return null; + } + + // 2. 校验门店归属 + if (existing.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "配送区域不属于该门店"); + } + + // 3. 更新字段 + existing.ZoneName = request.ZoneName.Trim(); + existing.PolygonGeoJson = request.PolygonGeoJson.Trim(); + existing.MinimumOrderAmount = request.MinimumOrderAmount; + existing.DeliveryFee = request.DeliveryFee; + existing.EstimatedMinutes = request.EstimatedMinutes; + existing.SortOrder = request.SortOrder; + + // 4. 持久化 + await _storeRepository.UpdateDeliveryZoneAsync(existing, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("更新配送区域 {DeliveryZoneId} 对应门店 {StoreId}", existing.Id, existing.StoreId); + + // 5. 返回 DTO + return StoreMapping.ToDto(existing); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreEmployeeShiftCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreEmployeeShiftCommandHandler.cs new file mode 100644 index 0000000..ed2a975 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreEmployeeShiftCommandHandler.cs @@ -0,0 +1,71 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新排班处理器。 +/// +public sealed class UpdateStoreEmployeeShiftCommandHandler( + IStoreRepository storeRepository, + IMerchantRepository merchantRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStoreEmployeeShiftCommand request, CancellationToken cancellationToken) + { + // 1. 读取排班 + var tenantId = tenantProvider.GetCurrentTenantId(); + var shift = await storeRepository.FindShiftByIdAsync(request.ShiftId, tenantId, cancellationToken); + if (shift is null) + { + return null; + } + + // 2. 校验门店归属 + if (shift.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "排班不属于该门店"); + } + + // 3. 校验员工归属 + var staff = await merchantRepository.FindStaffByIdAsync(request.StaffId, tenantId, cancellationToken); + if (staff is null || (staff.StoreId.HasValue && staff.StoreId != request.StoreId)) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "员工不存在或不属于该门店"); + } + + // 4. 冲突校验 + var shifts = await storeRepository.GetShiftsAsync(request.StoreId, tenantId, request.ShiftDate.Date, request.ShiftDate.Date, cancellationToken); + var hasConflict = shifts.Any(x => x.Id != request.ShiftId && x.StaffId == request.StaffId && x.ShiftDate == request.ShiftDate); + if (hasConflict) + { + throw new BusinessException(ErrorCodes.Conflict, "该员工当日已存在排班"); + } + + // 5. 更新字段 + shift.StaffId = request.StaffId; + shift.ShiftDate = request.ShiftDate.Date; + shift.StartTime = request.StartTime; + shift.EndTime = request.EndTime; + shift.RoleType = request.RoleType; + shift.Notes = request.Notes?.Trim(); + + // 6. 持久化 + await storeRepository.UpdateShiftAsync(shift, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新排班 {ShiftId} 员工 {StaffId} 门店 {StoreId}", shift.Id, shift.StaffId, shift.StoreId); + + // 7. 返回 DTO + return StoreMapping.ToDto(shift); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreHolidayCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreHolidayCommandHandler.cs new file mode 100644 index 0000000..878eaeb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreHolidayCommandHandler.cs @@ -0,0 +1,56 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新节假日配置处理器。 +/// +public sealed class UpdateStoreHolidayCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + private readonly IStoreRepository _storeRepository = storeRepository; + private readonly ITenantProvider _tenantProvider = tenantProvider; + private readonly ILogger _logger = logger; + + /// + public async Task Handle(UpdateStoreHolidayCommand request, CancellationToken cancellationToken) + { + // 1. 读取配置 + var tenantId = _tenantProvider.GetCurrentTenantId(); + var existing = await _storeRepository.FindHolidayByIdAsync(request.HolidayId, tenantId, cancellationToken); + if (existing is null) + { + return null; + } + + // 2. 校验门店归属 + if (existing.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "节假日配置不属于该门店"); + } + + // 3. 更新字段 + existing.Date = request.Date; + existing.IsClosed = request.IsClosed; + existing.Reason = request.Reason?.Trim(); + + // 4. 持久化 + await _storeRepository.UpdateHolidayAsync(existing, cancellationToken); + await _storeRepository.SaveChangesAsync(cancellationToken); + _logger.LogInformation("更新节假日 {HolidayId} 对应门店 {StoreId}", existing.Id, existing.StoreId); + + // 5. 返回 DTO + return StoreMapping.ToDto(existing); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStorePickupSlotCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStorePickupSlotCommandHandler.cs new file mode 100644 index 0000000..30355a3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStorePickupSlotCommandHandler.cs @@ -0,0 +1,57 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新自提档期处理器。 +/// +public sealed class UpdateStorePickupSlotCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStorePickupSlotCommand request, CancellationToken cancellationToken) + { + // 1. 查询档期 + var tenantId = tenantProvider.GetCurrentTenantId(); + var slot = await storeRepository.FindPickupSlotByIdAsync(request.SlotId, tenantId, cancellationToken); + if (slot is null || slot.StoreId != request.StoreId) + { + return null; + } + + // 2. 更新字段 + slot.Name = request.Name.Trim(); + slot.StartTime = request.StartTime; + slot.EndTime = request.EndTime; + slot.CutoffMinutes = request.CutoffMinutes; + slot.Capacity = request.Capacity; + slot.Weekdays = request.Weekdays; + slot.IsEnabled = request.IsEnabled; + await storeRepository.UpdatePickupSlotAsync(slot, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新自提档期 {SlotId}", request.SlotId); + return new StorePickupSlotDto + { + Id = slot.Id, + StoreId = slot.StoreId, + Name = slot.Name, + StartTime = slot.StartTime, + EndTime = slot.EndTime, + CutoffMinutes = slot.CutoffMinutes, + Capacity = slot.Capacity, + ReservedCount = slot.ReservedCount, + Weekdays = slot.Weekdays, + IsEnabled = slot.IsEnabled + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreStaffCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreStaffCommandHandler.cs new file mode 100644 index 0000000..28c1321 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreStaffCommandHandler.cs @@ -0,0 +1,55 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新门店员工处理器。 +/// +public sealed class UpdateStoreStaffCommandHandler( + IMerchantRepository merchantRepository, + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStoreStaffCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + return null; + } + + // 2. 读取员工 + var staff = await merchantRepository.FindStaffByIdAsync(request.StaffId, tenantId, cancellationToken); + if (staff is null || staff.StoreId != request.StoreId) + { + return null; + } + + // 3. 更新字段 + staff.Name = request.Name.Trim(); + staff.Phone = request.Phone.Trim(); + staff.Email = request.Email?.Trim(); + staff.RoleType = request.RoleType; + staff.Status = request.Status; + + // 4. 持久化 + await merchantRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新门店员工 {StaffId} 门店 {StoreId}", staff.Id, staff.StoreId); + + // 5. 返回 DTO + return StoreMapping.ToDto(staff); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableAreaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableAreaCommandHandler.cs new file mode 100644 index 0000000..e488d6b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableAreaCommandHandler.cs @@ -0,0 +1,59 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新桌台区域处理器。 +/// +public sealed class UpdateStoreTableAreaCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStoreTableAreaCommand request, CancellationToken cancellationToken) + { + // 1. 读取区域 + var tenantId = tenantProvider.GetCurrentTenantId(); + var area = await storeRepository.FindTableAreaByIdAsync(request.AreaId, tenantId, cancellationToken); + if (area is null) + { + return null; + } + + // 2. 校验门店归属 + if (area.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "区域不属于该门店"); + } + + // 3. 名称唯一校验 + var areas = await storeRepository.GetTableAreasAsync(request.StoreId, tenantId, cancellationToken); + var hasDuplicate = areas.Any(x => x.Id != request.AreaId && x.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)); + if (hasDuplicate) + { + throw new BusinessException(ErrorCodes.Conflict, "区域名称已存在"); + } + + // 4. 更新字段 + area.Name = request.Name.Trim(); + area.Description = request.Description?.Trim(); + area.SortOrder = request.SortOrder; + + // 5. 持久化 + await storeRepository.UpdateTableAreaAsync(area, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新桌台区域 {AreaId} 对应门店 {StoreId}", area.Id, area.StoreId); + + // 6. 返回 DTO + return StoreMapping.ToDto(area); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableCommandHandler.cs new file mode 100644 index 0000000..b6f8474 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpdateStoreTableCommandHandler.cs @@ -0,0 +1,72 @@ +using System.Linq; +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 更新桌码处理器。 +/// +public sealed class UpdateStoreTableCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpdateStoreTableCommand request, CancellationToken cancellationToken) + { + // 1. 读取桌码 + var tenantId = tenantProvider.GetCurrentTenantId(); + var table = await storeRepository.FindTableByIdAsync(request.TableId, tenantId, cancellationToken); + if (table is null) + { + return null; + } + + // 2. 校验门店归属 + if (table.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "桌码不属于该门店"); + } + + // 3. 校验区域归属 + if (request.AreaId.HasValue) + { + var area = await storeRepository.FindTableAreaByIdAsync(request.AreaId.Value, tenantId, cancellationToken); + if (area is null || area.StoreId != request.StoreId) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "桌台区域不存在或不属于该门店"); + } + } + + // 4. 校验桌码唯一 + var tables = await storeRepository.GetTablesAsync(request.StoreId, tenantId, cancellationToken); + var exists = tables.Any(x => x.Id != request.TableId && x.TableCode.Equals(request.TableCode, StringComparison.OrdinalIgnoreCase)); + if (exists) + { + throw new BusinessException(ErrorCodes.Conflict, "桌码已存在"); + } + + // 5. 更新字段 + table.AreaId = request.AreaId; + table.TableCode = request.TableCode.Trim(); + table.Capacity = request.Capacity; + table.Tags = request.Tags?.Trim(); + table.Status = request.Status; + + // 6. 持久化 + await storeRepository.UpdateTableAsync(table, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新桌码 {TableId} 对应门店 {StoreId}", table.Id, table.StoreId); + + // 7. 返回 DTO + return StoreMapping.ToDto(table); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpsertStorePickupSettingCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpsertStorePickupSettingCommandHandler.cs new file mode 100644 index 0000000..abb8866 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Handlers/UpsertStorePickupSettingCommandHandler.cs @@ -0,0 +1,63 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Stores.Commands; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Entities; +using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Stores.Handlers; + +/// +/// 自提配置维护处理器。 +/// +public sealed class UpsertStorePickupSettingCommandHandler( + IStoreRepository storeRepository, + ITenantProvider tenantProvider, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(UpsertStorePickupSettingCommand request, CancellationToken cancellationToken) + { + // 1. 校验门店存在 + var tenantId = tenantProvider.GetCurrentTenantId(); + var store = await storeRepository.FindByIdAsync(request.StoreId, tenantId, cancellationToken); + if (store is null) + { + throw new BusinessException(ErrorCodes.NotFound, "门店不存在"); + } + + // 2. 读取或创建配置 + var setting = await storeRepository.GetPickupSettingAsync(request.StoreId, tenantId, cancellationToken); + if (setting is null) + { + setting = new StorePickupSetting + { + TenantId = tenantId, + StoreId = request.StoreId + }; + await storeRepository.AddPickupSettingAsync(setting, cancellationToken); + } + + // 3. 更新字段 + setting.AllowToday = request.AllowToday; + setting.AllowDaysAhead = request.AllowDaysAhead; + setting.DefaultCutoffMinutes = request.DefaultCutoffMinutes; + setting.MaxQuantityPerOrder = request.MaxQuantityPerOrder; + await storeRepository.UpdatePickupSettingAsync(setting, cancellationToken); + await storeRepository.SaveChangesAsync(cancellationToken); + logger.LogInformation("更新门店 {StoreId} 自提配置", request.StoreId); + return new StorePickupSettingDto + { + Id = setting.Id, + StoreId = setting.StoreId, + AllowToday = setting.AllowToday, + AllowDaysAhead = setting.AllowDaysAhead, + DefaultCutoffMinutes = setting.DefaultCutoffMinutes, + MaxQuantityPerOrder = setting.MaxQuantityPerOrder + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ExportStoreTableQRCodesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ExportStoreTableQRCodesQuery.cs new file mode 100644 index 0000000..230fda0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ExportStoreTableQRCodesQuery.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 导出桌码二维码查询。 +/// +public sealed record ExportStoreTableQRCodesQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域筛选。 + /// + public long? AreaId { get; init; } + + /// + /// 内容模板,使用 {code} 占位。 + /// + public string? QrContentTemplate { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetAvailablePickupSlotsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetAvailablePickupSlotsQuery.cs new file mode 100644 index 0000000..f3310b8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetAvailablePickupSlotsQuery.cs @@ -0,0 +1,21 @@ +using System; +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 获取可用自提档期查询。 +/// +public sealed record GetAvailablePickupSlotsQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 目标日期(本地日期部分)。 + /// + public DateTime Date { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStorePickupSettingQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStorePickupSettingQuery.cs new file mode 100644 index 0000000..f0a4e28 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStorePickupSettingQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 获取门店自提配置查询。 +/// +public sealed record GetStorePickupSettingQuery : IRequest +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreTableContextQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreTableContextQuery.cs new file mode 100644 index 0000000..2ed641e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/GetStoreTableContextQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 桌码上下文查询。 +/// +public sealed record GetStoreTableContextQuery : IRequest +{ + /// + /// 桌码。 + /// + public string TableCode { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreBusinessHoursQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreBusinessHoursQuery.cs new file mode 100644 index 0000000..dac381a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreBusinessHoursQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 营业时段列表查询。 +/// +public sealed record ListStoreBusinessHoursQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreDeliveryZonesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreDeliveryZonesQuery.cs new file mode 100644 index 0000000..c3ed9ed --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreDeliveryZonesQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 配送区域列表查询。 +/// +public sealed record ListStoreDeliveryZonesQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreEmployeeShiftsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreEmployeeShiftsQuery.cs new file mode 100644 index 0000000..0684b06 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreEmployeeShiftsQuery.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 员工排班列表查询(支持日期区间)。 +/// +public sealed record ListStoreEmployeeShiftsQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 开始日期(含),默认今日。 + /// + public DateTime? From { get; init; } + + /// + /// 结束日期(含),默认今日+7。 + /// + public DateTime? To { get; init; } + + /// + /// 可选员工筛选。 + /// + public long? StaffId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreHolidaysQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreHolidaysQuery.cs new file mode 100644 index 0000000..bf14cd2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreHolidaysQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 门店节假日列表查询。 +/// +public sealed record ListStoreHolidaysQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStorePickupSlotsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStorePickupSlotsQuery.cs new file mode 100644 index 0000000..88660af --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStorePickupSlotsQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 门店档期列表查询。 +/// +public sealed record ListStorePickupSlotsQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreStaffQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreStaffQuery.cs new file mode 100644 index 0000000..23004f9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreStaffQuery.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 门店员工列表查询。 +/// +public sealed record ListStoreStaffQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 角色筛选。 + /// + public StaffRoleType? RoleType { get; init; } + + /// + /// 状态筛选。 + /// + public StaffStatus? Status { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTableAreasQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTableAreasQuery.cs new file mode 100644 index 0000000..6af7efc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTableAreasQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 门店桌台区域列表查询。 +/// +public sealed record ListStoreTableAreasQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTablesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTablesQuery.cs new file mode 100644 index 0000000..707e915 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Queries/ListStoreTablesQuery.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Stores.Enums; + +namespace TakeoutSaaS.Application.App.Stores.Queries; + +/// +/// 门店桌码列表查询。 +/// +public sealed record ListStoreTablesQuery : IRequest> +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; init; } + + /// + /// 区域筛选。 + /// + public long? AreaId { get; init; } + + /// + /// 状态筛选。 + /// + public StoreTableStatus? Status { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs new file mode 100644 index 0000000..8a9a0d2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/StoreMapping.cs @@ -0,0 +1,137 @@ +using TakeoutSaaS.Application.App.Stores.Dto; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Stores.Entities; + +namespace TakeoutSaaS.Application.App.Stores; + +/// +/// 门店相关映射助手。 +/// +public static class StoreMapping +{ + /// + /// 映射营业时段 DTO。 + /// + /// 营业时段实体。 + /// DTO。 + public static StoreBusinessHourDto ToDto(StoreBusinessHour hour) => new() + { + Id = hour.Id, + TenantId = hour.TenantId, + StoreId = hour.StoreId, + DayOfWeek = hour.DayOfWeek, + HourType = hour.HourType, + StartTime = hour.StartTime, + EndTime = hour.EndTime, + CapacityLimit = hour.CapacityLimit, + Notes = hour.Notes, + CreatedAt = hour.CreatedAt + }; + + /// + /// 映射配送区域 DTO。 + /// + /// 配送区域实体。 + /// DTO。 + public static StoreDeliveryZoneDto ToDto(StoreDeliveryZone zone) => new() + { + Id = zone.Id, + TenantId = zone.TenantId, + StoreId = zone.StoreId, + ZoneName = zone.ZoneName, + PolygonGeoJson = zone.PolygonGeoJson, + MinimumOrderAmount = zone.MinimumOrderAmount, + DeliveryFee = zone.DeliveryFee, + EstimatedMinutes = zone.EstimatedMinutes, + SortOrder = zone.SortOrder, + CreatedAt = zone.CreatedAt + }; + + /// + /// 映射节假日 DTO。 + /// + /// 节假日实体。 + /// DTO。 + public static StoreHolidayDto ToDto(StoreHoliday holiday) => new() + { + Id = holiday.Id, + TenantId = holiday.TenantId, + StoreId = holiday.StoreId, + Date = holiday.Date, + IsClosed = holiday.IsClosed, + Reason = holiday.Reason, + CreatedAt = holiday.CreatedAt + }; + + /// + /// 映射桌台区域 DTO。 + /// + /// 区域实体。 + /// DTO。 + public static StoreTableAreaDto ToDto(StoreTableArea area) => new() + { + Id = area.Id, + TenantId = area.TenantId, + StoreId = area.StoreId, + Name = area.Name, + Description = area.Description, + SortOrder = area.SortOrder, + CreatedAt = area.CreatedAt + }; + + /// + /// 映射桌台 DTO。 + /// + /// 桌台实体。 + /// DTO。 + public static StoreTableDto ToDto(StoreTable table) => new() + { + Id = table.Id, + TenantId = table.TenantId, + StoreId = table.StoreId, + AreaId = table.AreaId, + TableCode = table.TableCode, + Capacity = table.Capacity, + Tags = table.Tags, + Status = table.Status, + QrCodeUrl = table.QrCodeUrl, + CreatedAt = table.CreatedAt + }; + + /// + /// 映射排班 DTO。 + /// + /// 排班实体。 + /// DTO。 + public static StoreEmployeeShiftDto ToDto(StoreEmployeeShift shift) => new() + { + Id = shift.Id, + TenantId = shift.TenantId, + StoreId = shift.StoreId, + StaffId = shift.StaffId, + ShiftDate = shift.ShiftDate, + StartTime = shift.StartTime, + EndTime = shift.EndTime, + RoleType = shift.RoleType, + Notes = shift.Notes, + CreatedAt = shift.CreatedAt + }; + + /// + /// 映射门店员工 DTO。 + /// + /// 员工实体。 + /// DTO。 + public static StoreStaffDto ToDto(MerchantStaff staff) => new() + { + Id = staff.Id, + TenantId = staff.TenantId, + MerchantId = staff.MerchantId, + StoreId = staff.StoreId, + Name = staff.Name, + Phone = staff.Phone, + Email = staff.Email, + RoleType = staff.RoleType, + Status = staff.Status + }; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreBusinessHourCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreBusinessHourCommandValidator.cs new file mode 100644 index 0000000..b0f1b80 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreBusinessHourCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建营业时段命令验证器。 +/// +public sealed class CreateStoreBusinessHourCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreBusinessHourCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.StartTime).LessThan(x => x.EndTime).WithMessage("结束时间必须晚于开始时间"); + RuleFor(x => x.CapacityLimit).GreaterThanOrEqualTo(0).When(x => x.CapacityLimit.HasValue); + RuleFor(x => x.Notes).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreDeliveryZoneCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreDeliveryZoneCommandValidator.cs new file mode 100644 index 0000000..b8878e3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreDeliveryZoneCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建配送区域命令验证器。 +/// +public sealed class CreateStoreDeliveryZoneCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreDeliveryZoneCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ZoneName).NotEmpty().MaximumLength(64); + RuleFor(x => x.PolygonGeoJson).NotEmpty(); + RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).When(x => x.MinimumOrderAmount.HasValue); + RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue); + RuleFor(x => x.EstimatedMinutes).GreaterThan(0).When(x => x.EstimatedMinutes.HasValue); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreEmployeeShiftCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreEmployeeShiftCommandValidator.cs new file mode 100644 index 0000000..9a0cb21 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreEmployeeShiftCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建排班命令验证器。 +/// +public sealed class CreateStoreEmployeeShiftCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreEmployeeShiftCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.StaffId).GreaterThan(0); + RuleFor(x => x.ShiftDate).NotEmpty(); + RuleFor(x => x.StartTime).LessThan(x => x.EndTime).WithMessage("结束时间必须晚于开始时间"); + RuleFor(x => x.Notes).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreHolidayCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreHolidayCommandValidator.cs new file mode 100644 index 0000000..b6c140f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreHolidayCommandValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建节假日命令验证器。 +/// +public sealed class CreateStoreHolidayCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreHolidayCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Date).NotEmpty(); + RuleFor(x => x.Reason).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStorePickupSlotCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStorePickupSlotCommandValidator.cs new file mode 100644 index 0000000..75def24 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStorePickupSlotCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建自提档期验证器。 +/// +public sealed class CreateStorePickupSlotCommandValidator : AbstractValidator +{ + /// + /// 初始化规则。 + /// + public CreateStorePickupSlotCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Capacity).GreaterThan(0); + RuleFor(x => x.Weekdays).NotEmpty().MaximumLength(32); + RuleFor(x => x.CutoffMinutes).GreaterThanOrEqualTo(0); + RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreStaffCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreStaffCommandValidator.cs new file mode 100644 index 0000000..7e4e44b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreStaffCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建门店员工命令验证器。 +/// +public sealed class CreateStoreStaffCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreStaffCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Phone).NotEmpty().MaximumLength(32); + RuleFor(x => x.Email).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.Email)); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreTableAreaCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreTableAreaCommandValidator.cs new file mode 100644 index 0000000..9f6649b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/CreateStoreTableAreaCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 创建桌台区域命令验证器。 +/// +public sealed class CreateStoreTableAreaCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public CreateStoreTableAreaCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Description).MaximumLength(256); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStorePickupSlotCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStorePickupSlotCommandValidator.cs new file mode 100644 index 0000000..a7b70f5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/DeleteStorePickupSlotCommandValidator.cs @@ -0,0 +1,19 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 删除自提档期验证器。 +/// +public sealed class DeleteStorePickupSlotCommandValidator : AbstractValidator +{ + /// + /// 初始化规则。 + /// + public DeleteStorePickupSlotCommandValidator() + { + RuleFor(x => x.SlotId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/GenerateStoreTablesCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/GenerateStoreTablesCommandValidator.cs new file mode 100644 index 0000000..534caca --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/GenerateStoreTablesCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 批量生成桌码命令验证器。 +/// +public sealed class GenerateStoreTablesCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public GenerateStoreTablesCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.TableCodePrefix).NotEmpty().MaximumLength(16); + RuleFor(x => x.StartNumber).GreaterThan(0); + RuleFor(x => x.Count).GreaterThan(0).LessThanOrEqualTo(500); + RuleFor(x => x.DefaultCapacity).GreaterThan(0).LessThanOrEqualTo(50); + RuleFor(x => x.Tags).MaximumLength(128); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/GetStoreTableContextQueryValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/GetStoreTableContextQueryValidator.cs new file mode 100644 index 0000000..bb80b34 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/GetStoreTableContextQueryValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Queries; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 桌码上下文查询验证器。 +/// +public sealed class GetStoreTableContextQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public GetStoreTableContextQueryValidator() + { + RuleFor(x => x.TableCode).NotEmpty().MaximumLength(32); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreBusinessHourCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreBusinessHourCommandValidator.cs new file mode 100644 index 0000000..e7e76d5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreBusinessHourCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新营业时段命令验证器。 +/// +public sealed class UpdateStoreBusinessHourCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreBusinessHourCommandValidator() + { + RuleFor(x => x.BusinessHourId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.StartTime).LessThan(x => x.EndTime).WithMessage("结束时间必须晚于开始时间"); + RuleFor(x => x.CapacityLimit).GreaterThanOrEqualTo(0).When(x => x.CapacityLimit.HasValue); + RuleFor(x => x.Notes).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreDeliveryZoneCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreDeliveryZoneCommandValidator.cs new file mode 100644 index 0000000..5de6927 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreDeliveryZoneCommandValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新配送区域命令验证器。 +/// +public sealed class UpdateStoreDeliveryZoneCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreDeliveryZoneCommandValidator() + { + RuleFor(x => x.DeliveryZoneId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.ZoneName).NotEmpty().MaximumLength(64); + RuleFor(x => x.PolygonGeoJson).NotEmpty(); + RuleFor(x => x.MinimumOrderAmount).GreaterThanOrEqualTo(0).When(x => x.MinimumOrderAmount.HasValue); + RuleFor(x => x.DeliveryFee).GreaterThanOrEqualTo(0).When(x => x.DeliveryFee.HasValue); + RuleFor(x => x.EstimatedMinutes).GreaterThan(0).When(x => x.EstimatedMinutes.HasValue); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreEmployeeShiftCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreEmployeeShiftCommandValidator.cs new file mode 100644 index 0000000..53eb742 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreEmployeeShiftCommandValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新排班命令验证器。 +/// +public sealed class UpdateStoreEmployeeShiftCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreEmployeeShiftCommandValidator() + { + RuleFor(x => x.ShiftId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.StaffId).GreaterThan(0); + RuleFor(x => x.ShiftDate).NotEmpty(); + RuleFor(x => x.StartTime).LessThan(x => x.EndTime).WithMessage("结束时间必须晚于开始时间"); + RuleFor(x => x.Notes).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreHolidayCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreHolidayCommandValidator.cs new file mode 100644 index 0000000..4567bef --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreHolidayCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新节假日命令验证器。 +/// +public sealed class UpdateStoreHolidayCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreHolidayCommandValidator() + { + RuleFor(x => x.HolidayId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Date).NotEmpty(); + RuleFor(x => x.Reason).MaximumLength(256); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStorePickupSlotCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStorePickupSlotCommandValidator.cs new file mode 100644 index 0000000..7ae91d0 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStorePickupSlotCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新自提档期验证器。 +/// +public sealed class UpdateStorePickupSlotCommandValidator : AbstractValidator +{ + /// + /// 初始化规则。 + /// + public UpdateStorePickupSlotCommandValidator() + { + RuleFor(x => x.SlotId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Capacity).GreaterThan(0); + RuleFor(x => x.Weekdays).NotEmpty().MaximumLength(32); + RuleFor(x => x.CutoffMinutes).GreaterThanOrEqualTo(0); + RuleFor(x => x.EndTime).GreaterThan(x => x.StartTime); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreStaffCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreStaffCommandValidator.cs new file mode 100644 index 0000000..27794d2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreStaffCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新门店员工命令验证器。 +/// +public sealed class UpdateStoreStaffCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreStaffCommandValidator() + { + RuleFor(x => x.StaffId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Phone).NotEmpty().MaximumLength(32); + RuleFor(x => x.Email).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.Email)); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableAreaCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableAreaCommandValidator.cs new file mode 100644 index 0000000..31d2b98 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableAreaCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新桌台区域命令验证器。 +/// +public sealed class UpdateStoreTableAreaCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreTableAreaCommandValidator() + { + RuleFor(x => x.AreaId).GreaterThan(0); + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.Name).NotEmpty().MaximumLength(64); + RuleFor(x => x.Description).MaximumLength(256); + RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableCommandValidator.cs new file mode 100644 index 0000000..340453e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpdateStoreTableCommandValidator.cs @@ -0,0 +1,22 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 更新桌码命令验证器。 +/// +public sealed class UpdateStoreTableCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + public UpdateStoreTableCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.TableId).GreaterThan(0); + RuleFor(x => x.TableCode).NotEmpty().MaximumLength(32); + RuleFor(x => x.Capacity).GreaterThan(0).LessThanOrEqualTo(50); + RuleFor(x => x.Tags).MaximumLength(128); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpsertStorePickupSettingCommandValidator.cs b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpsertStorePickupSettingCommandValidator.cs new file mode 100644 index 0000000..2cc3d04 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Stores/Validators/UpsertStorePickupSettingCommandValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using TakeoutSaaS.Application.App.Stores.Commands; + +namespace TakeoutSaaS.Application.App.Stores.Validators; + +/// +/// 自提配置验证器。 +/// +public sealed class UpsertStorePickupSettingCommandValidator : AbstractValidator +{ + /// + /// 初始化规则。 + /// + public UpsertStorePickupSettingCommandValidator() + { + RuleFor(x => x.StoreId).GreaterThan(0); + RuleFor(x => x.AllowDaysAhead).GreaterThanOrEqualTo(0); + RuleFor(x => x.DefaultCutoffMinutes).GreaterThanOrEqualTo(0); + RuleFor(x => x.MaxQuantityPerOrder).GreaterThan(0).When(x => x.MaxQuantityPerOrder.HasValue); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs new file mode 100644 index 0000000..5c052be --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ChangeTenantSubscriptionPlanCommand.cs @@ -0,0 +1,39 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 套餐升降配命令。 +/// +public sealed record ChangeTenantSubscriptionPlanCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } + + /// + /// 现有订阅 ID。 + /// + [Required] + public long TenantSubscriptionId { get; init; } + + /// + /// 目标套餐 ID。 + /// + [Required] + public long TargetPackageId { get; init; } + + /// + /// 是否立即生效,否则在下一结算周期生效。 + /// + public bool Immediate { get; init; } + + /// + /// 调整备注。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CheckTenantQuotaCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CheckTenantQuotaCommand.cs new file mode 100644 index 0000000..3941e81 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CheckTenantQuotaCommand.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 校验并消费租户配额命令。 +/// +public sealed record CheckTenantQuotaCommand : IRequest +{ + /// + /// 目标租户 ID。 + /// + public long TenantId { get; init; } + + /// + /// 配额类型。 + /// + public TenantQuotaType QuotaType { get; init; } + + /// + /// 本次申请使用量。 + /// + public decimal Delta { get; init; } = 1; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs new file mode 100644 index 0000000..dd6735a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantAnnouncementCommand.cs @@ -0,0 +1,51 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 创建租户公告命令。 +/// +public sealed record CreateTenantAnnouncementCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 公告标题。 + /// + public string Title { get; init; } = string.Empty; + + /// + /// 公告正文内容。 + /// + public string Content { get; init; } = string.Empty; + + /// + /// 公告类型。 + /// + public TenantAnnouncementType AnnouncementType { get; init; } = TenantAnnouncementType.System; + + /// + /// 优先级,数值越大越靠前。 + /// + public int Priority { get; init; } = 0; + + /// + /// 生效开始时间(UTC)。 + /// + public DateTime EffectiveFrom { get; init; } = DateTime.UtcNow; + + /// + /// 生效结束时间(UTC),为空则长期有效。 + /// + public DateTime? EffectiveTo { get; init; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs new file mode 100644 index 0000000..1bc64a7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantBillingCommand.cs @@ -0,0 +1,56 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 创建租户账单命令。 +/// +public sealed record CreateTenantBillingCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 账单编号。 + /// + public string StatementNo { get; init; } = string.Empty; + + /// + /// 计费周期开始时间(UTC)。 + /// + public DateTime PeriodStart { get; init; } + + /// + /// 计费周期结束时间(UTC)。 + /// + public DateTime PeriodEnd { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 已付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 账单状态。 + /// + public TenantBillingStatus Status { get; init; } = TenantBillingStatus.Pending; + + /// + /// 到期日(UTC)。 + /// + public DateTime DueDate { get; init; } + + /// + /// 账单明细 JSON。 + /// + public string? LineItemsJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs new file mode 100644 index 0000000..c809fca --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantPackageCommand.cs @@ -0,0 +1,71 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 创建租户套餐命令。 +/// +public sealed record CreateTenantPackageCommand : IRequest +{ + /// + /// 套餐名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 套餐描述。 + /// + public string? Description { get; init; } + + /// + /// 套餐类型。 + /// + public TenantPackageType PackageType { get; init; } = TenantPackageType.Standard; + + /// + /// 月付价格。 + /// + public decimal? MonthlyPrice { get; init; } + + /// + /// 年付价格。 + /// + public decimal? YearlyPrice { get; init; } + + /// + /// 最大门店数。 + /// + public int? MaxStoreCount { get; init; } + + /// + /// 最大账号数。 + /// + public int? MaxAccountCount { get; init; } + + /// + /// 存储上限(GB)。 + /// + public int? MaxStorageGb { get; init; } + + /// + /// 短信额度。 + /// + public int? MaxSmsCredits { get; init; } + + /// + /// 配送单上限。 + /// + public int? MaxDeliveryOrders { get; init; } + + /// + /// 权益明细 JSON。 + /// + public string? FeaturePoliciesJson { get; init; } + + /// + /// 是否可售。 + /// + public bool IsActive { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs new file mode 100644 index 0000000..b31a92d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/CreateTenantSubscriptionCommand.cs @@ -0,0 +1,38 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 新建或续费订阅。 +/// +public sealed record CreateTenantSubscriptionCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } + + /// + /// 套餐 ID。 + /// + [Required] + public long TenantPackageId { get; init; } + + /// + /// 订阅时长(月)。 + /// + public int DurationMonths { get; init; } + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } + + /// + /// 备注信息。 + /// + public string? Notes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs new file mode 100644 index 0000000..3cd5964 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantAnnouncementCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 删除租户公告命令。 +/// +public sealed record DeleteTenantAnnouncementCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 公告 ID。 + /// + public long AnnouncementId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantPackageCommand.cs new file mode 100644 index 0000000..9f46a76 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/DeleteTenantPackageCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 删除租户套餐命令。 +/// +public sealed record DeleteTenantPackageCommand : IRequest +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs new file mode 100644 index 0000000..97ce6e1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantAnnouncementReadCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 标记公告已读命令。 +/// +public sealed record MarkTenantAnnouncementReadCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 公告 ID。 + /// + public long AnnouncementId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs new file mode 100644 index 0000000..e2c9f18 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantBillingPaidCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 标记租户账单已支付命令。 +/// +public sealed record MarkTenantBillingPaidCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 账单 ID。 + /// + public long BillingId { get; init; } + + /// + /// 本次支付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 支付时间(UTC)。 + /// + public DateTime PaidAt { get; init; } = DateTime.UtcNow; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs new file mode 100644 index 0000000..149a74a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/MarkTenantNotificationReadCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 标记通知已读命令。 +/// +public sealed record MarkTenantNotificationReadCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 通知 ID。 + /// + public long NotificationId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs new file mode 100644 index 0000000..d26a96b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/RegisterTenantCommand.cs @@ -0,0 +1,71 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 注册租户命令。 +/// +public sealed record RegisterTenantCommand : IRequest +{ + /// + /// 唯一租户编码。 + /// + [Required] + [StringLength(64)] + public string Code { get; init; } = string.Empty; + + /// + /// 租户名称。 + /// + [Required] + [StringLength(128)] + public string Name { get; init; } = string.Empty; + + /// + /// 租户简称。 + /// + public string? ShortName { get; init; } + + /// + /// 行业类型。 + /// + public string? Industry { get; init; } + + /// + /// 联系人姓名。 + /// + public string? ContactName { get; init; } + + /// + /// 联系人电话。 + /// + public string? ContactPhone { get; init; } + + /// + /// 联系人邮箱。 + /// + public string? ContactEmail { get; init; } + + /// + /// 购买套餐 ID。 + /// + [Required] + public long TenantPackageId { get; init; } + + /// + /// 订阅时长(月),默认 12 个月。 + /// + public int DurationMonths { get; init; } = 12; + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } = true; + + /// + /// 生效时间(UTC),为空则立即生效。 + /// + public DateTime? EffectiveFrom { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs new file mode 100644 index 0000000..7b1c6b6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/ReviewTenantCommand.cs @@ -0,0 +1,27 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 审核租户命令。 +/// +public sealed record ReviewTenantCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } + + /// + /// 是否通过审核。 + /// + public bool Approve { get; init; } + + /// + /// 审核备注或拒绝原因。 + /// + public string? Reason { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs new file mode 100644 index 0000000..94de46d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/SubmitTenantVerificationCommand.cs @@ -0,0 +1,67 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 提交租户实名认证资料。 +/// +public sealed record SubmitTenantVerificationCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + [Required] + public long TenantId { get; init; } + + /// + /// 营业执照编号。 + /// + public string? BusinessLicenseNumber { get; init; } + + /// + /// 营业执照扫描件地址。 + /// + public string? BusinessLicenseUrl { get; init; } + + /// + /// 法人姓名。 + /// + public string? LegalPersonName { get; init; } + + /// + /// 法人身份证号。 + /// + public string? LegalPersonIdNumber { get; init; } + + /// + /// 法人身份证人像面图片地址。 + /// + public string? LegalPersonIdFrontUrl { get; init; } + + /// + /// 法人身份证国徽面图片地址。 + /// + public string? LegalPersonIdBackUrl { get; init; } + + /// + /// 对公账户户名。 + /// + public string? BankAccountName { get; init; } + + /// + /// 对公银行账号。 + /// + public string? BankAccountNumber { get; init; } + + /// + /// 开户行名称。 + /// + public string? BankName { get; init; } + + /// + /// 其他补充资料 JSON。 + /// + public string? AdditionalDataJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs new file mode 100644 index 0000000..b495d69 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantAnnouncementCommand.cs @@ -0,0 +1,56 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 更新租户公告命令。 +/// +public sealed record UpdateTenantAnnouncementCommand : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 公告 ID。 + /// + public long AnnouncementId { get; init; } + + /// + /// 公告标题。 + /// + public string Title { get; init; } = string.Empty; + + /// + /// 公告内容。 + /// + public string Content { get; init; } = string.Empty; + + /// + /// 公告类型。 + /// + public TenantAnnouncementType AnnouncementType { get; init; } = TenantAnnouncementType.System; + + /// + /// 优先级,数值越大越靠前。 + /// + public int Priority { get; init; } = 0; + + /// + /// 生效开始时间(UTC)。 + /// + public DateTime EffectiveFrom { get; init; } = DateTime.UtcNow; + + /// + /// 生效结束时间(UTC),为空则长期有效。 + /// + public DateTime? EffectiveTo { get; init; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs new file mode 100644 index 0000000..a4529d3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Commands/UpdateTenantPackageCommand.cs @@ -0,0 +1,76 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Commands; + +/// +/// 更新租户套餐命令。 +/// +public sealed record UpdateTenantPackageCommand : IRequest +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } + + /// + /// 套餐名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 套餐描述。 + /// + public string? Description { get; init; } + + /// + /// 套餐类型。 + /// + public TenantPackageType PackageType { get; init; } = TenantPackageType.Standard; + + /// + /// 月付价格。 + /// + public decimal? MonthlyPrice { get; init; } + + /// + /// 年付价格。 + /// + public decimal? YearlyPrice { get; init; } + + /// + /// 最大门店数。 + /// + public int? MaxStoreCount { get; init; } + + /// + /// 最大账号数。 + /// + public int? MaxAccountCount { get; init; } + + /// + /// 存储上限(GB)。 + /// + public int? MaxStorageGb { get; init; } + + /// + /// 短信额度。 + /// + public int? MaxSmsCredits { get; init; } + + /// + /// 配送单上限。 + /// + public int? MaxDeliveryOrders { get; init; } + + /// + /// 权益明细 JSON。 + /// + public string? FeaturePoliciesJson { get; init; } + + /// + /// 是否可售。 + /// + public bool IsActive { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/QuotaCheckResultDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/QuotaCheckResultDto.cs new file mode 100644 index 0000000..b9ce7e6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/QuotaCheckResultDto.cs @@ -0,0 +1,29 @@ +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 配额校验结果。 +/// +public sealed class QuotaCheckResultDto +{ + /// + /// 配额类型。 + /// + public TenantQuotaType QuotaType { get; init; } + + /// + /// 当前配额上限,null 表示无限制。 + /// + public decimal? Limit { get; init; } + + /// + /// 已使用数量。 + /// + public decimal Used { get; init; } + + /// + /// 剩余额度,null 表示无限制。 + /// + public decimal? Remaining { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs new file mode 100644 index 0000000..79fbbe8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAnnouncementDto.cs @@ -0,0 +1,68 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户公告 DTO。 +/// +public sealed class TenantAnnouncementDto +{ + /// + /// 公告 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 公告标题。 + /// + public string Title { get; init; } = string.Empty; + + /// + /// 公告正文内容。 + /// + public string Content { get; init; } = string.Empty; + + /// + /// 公告类型。 + /// + public TenantAnnouncementType AnnouncementType { get; init; } + + /// + /// 优先级,数值越大越靠前。 + /// + public int Priority { get; init; } + + /// + /// 生效开始时间(UTC)。 + /// + public DateTime EffectiveFrom { get; init; } + + /// + /// 生效结束时间(UTC),为空则长期有效。 + /// + public DateTime? EffectiveTo { get; init; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; init; } + + /// + /// 当前用户是否已读。 + /// + public bool IsRead { get; init; } + + /// + /// 已读时间(UTC)。 + /// + public DateTime? ReadAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAuditLogDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAuditLogDto.cs new file mode 100644 index 0000000..ff3f9dc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantAuditLogDto.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户审核日志 DTO。 +/// +public sealed class TenantAuditLogDto +{ + /// + /// 日志 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 动作。 + /// + public TenantAuditAction Action { get; init; } + + /// + /// 标题。 + /// + public string Title { get; init; } = string.Empty; + + /// + /// 描述。 + /// + public string? Description { get; init; } + + /// + /// 操作人。 + /// + public string? OperatorName { get; init; } + + /// + /// 原状态。 + /// + public TenantStatus? PreviousStatus { get; init; } + + /// + /// 新状态。 + /// + public TenantStatus? CurrentStatus { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs new file mode 100644 index 0000000..1007224 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantBillingDto.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户账单 DTO。 +/// +public sealed class TenantBillingDto +{ + /// + /// 账单 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 账单编号。 + /// + public string StatementNo { get; init; } = string.Empty; + + /// + /// 计费周期开始时间(UTC)。 + /// + public DateTime PeriodStart { get; init; } + + /// + /// 计费周期结束时间(UTC)。 + /// + public DateTime PeriodEnd { get; init; } + + /// + /// 应付金额。 + /// + public decimal AmountDue { get; init; } + + /// + /// 已付金额。 + /// + public decimal AmountPaid { get; init; } + + /// + /// 账单状态。 + /// + public TenantBillingStatus Status { get; init; } + + /// + /// 到期日(UTC)。 + /// + public DateTime DueDate { get; init; } + + /// + /// 账单明细 JSON。 + /// + public string? LineItemsJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDetailDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDetailDto.cs new file mode 100644 index 0000000..9035d93 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDetailDto.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户详情 DTO。 +/// +public sealed class TenantDetailDto +{ + /// + /// 基础信息。 + /// + public TenantDto Tenant { get; init; } = new(); + + /// + /// 实名信息。 + /// + public TenantVerificationDto? Verification { get; init; } + + /// + /// 当前订阅。 + /// + public TenantSubscriptionDto? Subscription { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDto.cs new file mode 100644 index 0000000..7f490ad --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantDto.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户基础信息 DTO。 +/// +public sealed class TenantDto +{ + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 简称。 + /// + public string? ShortName { get; init; } + + /// + /// 联系人。 + /// + public string? ContactName { get; init; } + + /// + /// 联系电话。 + /// + public string? ContactPhone { get; init; } + + /// + /// 邮箱。 + /// + public string? ContactEmail { get; init; } + + /// + /// 当前状态。 + /// + public TenantStatus Status { get; init; } + + /// + /// 实名状态。 + /// + public TenantVerificationStatus VerificationStatus { get; init; } + + /// + /// 当前套餐 ID。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? CurrentPackageId { get; init; } + + /// + /// 当前订阅有效期开始。 + /// + public DateTime? EffectiveFrom { get; init; } + + /// + /// 当前订阅有效期结束。 + /// + public DateTime? EffectiveTo { get; init; } + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs new file mode 100644 index 0000000..9a33244 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantNotificationDto.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户通知 DTO。 +/// +public sealed class TenantNotificationDto +{ + /// + /// 通知 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID(雪花算法,序列化为字符串)。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 通知标题。 + /// + public string Title { get; init; } = string.Empty; + + /// + /// 通知内容。 + /// + public string Message { get; init; } = string.Empty; + + /// + /// 通道类型(如站内信、短信、邮件)。 + /// + public TenantNotificationChannel Channel { get; init; } + + /// + /// 通知等级。 + /// + public TenantNotificationSeverity Severity { get; init; } + + /// + /// 发送时间(UTC)。 + /// + public DateTime SentAt { get; init; } + + /// + /// 阅读时间(UTC)。 + /// + public DateTime? ReadAt { get; init; } + + /// + /// 附加元数据 JSON。 + /// + public string? MetadataJson { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs new file mode 100644 index 0000000..52f9e90 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantPackageDto.cs @@ -0,0 +1,77 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户套餐 DTO。 +/// +public sealed class TenantPackageDto +{ + /// + /// 套餐 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 套餐名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 描述。 + /// + public string? Description { get; init; } + + /// + /// 套餐类型。 + /// + public TenantPackageType PackageType { get; init; } + + /// + /// 月付价格。 + /// + public decimal? MonthlyPrice { get; init; } + + /// + /// 年付价格。 + /// + public decimal? YearlyPrice { get; init; } + + /// + /// 最大门店数。 + /// + public int? MaxStoreCount { get; init; } + + /// + /// 最大账号数。 + /// + public int? MaxAccountCount { get; init; } + + /// + /// 存储上限(GB)。 + /// + public int? MaxStorageGb { get; init; } + + /// + /// 短信额度。 + /// + public int? MaxSmsCredits { get; init; } + + /// + /// 配送单上限。 + /// + public int? MaxDeliveryOrders { get; init; } + + /// + /// 权益明细 JSON。 + /// + public string? FeaturePoliciesJson { get; init; } + + /// + /// 是否可售。 + /// + public bool IsActive { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantSubscriptionDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantSubscriptionDto.cs new file mode 100644 index 0000000..3699896 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantSubscriptionDto.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户订阅 DTO。 +/// +public sealed class TenantSubscriptionDto +{ + /// + /// 订阅 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 套餐 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantPackageId { get; init; } + + /// + /// 状态。 + /// + public SubscriptionStatus Status { get; init; } + + /// + /// 生效时间。 + /// + public DateTime EffectiveFrom { get; init; } + + /// + /// 到期时间。 + /// + public DateTime EffectiveTo { get; init; } + + /// + /// 下次扣费时间。 + /// + public DateTime? NextBillingDate { get; init; } + + /// + /// 是否自动续费。 + /// + public bool AutoRenew { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantVerificationDto.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantVerificationDto.cs new file mode 100644 index 0000000..7f39c8f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Dto/TenantVerificationDto.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.App.Tenants.Dto; + +/// +/// 租户实名认证 DTO。 +/// +public sealed class TenantVerificationDto +{ + /// + /// 主键。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户标识。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 状态。 + /// + public TenantVerificationStatus Status { get; init; } + + /// + /// 营业执照号。 + /// + public string? BusinessLicenseNumber { get; init; } + + /// + /// 营业执照图片。 + /// + public string? BusinessLicenseUrl { get; init; } + + /// + /// 法人姓名。 + /// + public string? LegalPersonName { get; init; } + + /// + /// 法人身份证号。 + /// + public string? LegalPersonIdNumber { get; init; } + + /// + /// 银行账号。 + /// + public string? BankAccountNumber { get; init; } + + /// + /// 银行名称。 + /// + public string? BankName { get; init; } + + /// + /// 审核备注。 + /// + public string? ReviewRemarks { get; init; } + + /// + /// 最新审核人。 + /// + public string? ReviewedByName { get; init; } + + /// + /// 审核时间。 + /// + public DateTime? ReviewedAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs new file mode 100644 index 0000000..de73ccf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ChangeTenantSubscriptionPlanCommandHandler.cs @@ -0,0 +1,76 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 套餐升降配处理器。 +/// +public sealed class ChangeTenantSubscriptionPlanCommandHandler( + ITenantRepository tenantRepository, + IIdGenerator idGenerator) + : IRequestHandler +{ + /// + public async Task Handle(ChangeTenantSubscriptionPlanCommand request, CancellationToken cancellationToken) + { + // 1. 校验租户与订阅存在性 + _ = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var subscription = await tenantRepository.FindSubscriptionByIdAsync(request.TenantId, request.TenantSubscriptionId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "订阅不存在"); + + var previousPackage = subscription.TenantPackageId; + + // 2. 根据立即生效或排期设置目标套餐 + if (request.Immediate) + { + subscription.TenantPackageId = request.TargetPackageId; + subscription.ScheduledPackageId = null; + } + else + { + subscription.ScheduledPackageId = request.TargetPackageId; + } + + // 3. 更新订阅并记录变更历史 + await tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken); + await tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory + { + Id = idGenerator.NextId(), + TenantId = subscription.TenantId, + TenantSubscriptionId = subscription.Id, + FromPackageId = previousPackage, + ToPackageId = request.TargetPackageId, + ChangeType = SubscriptionChangeType.Upgrade, + EffectiveFrom = subscription.EffectiveFrom, + EffectiveTo = subscription.EffectiveTo, + Notes = request.Notes + }, cancellationToken); + + // 4. 记录审计日志 + await tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + TenantId = subscription.TenantId, + Action = TenantAuditAction.SubscriptionPlanChanged, + Title = request.Immediate ? "套餐立即变更" : "套餐排期变更", + Description = request.Notes, + PreviousStatus = null, + CurrentStatus = null + }, cancellationToken); + + // 5. 保存并返回 DTO + await tenantRepository.SaveChangesAsync(cancellationToken); + + return subscription.ToSubscriptionDto() + ?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅更新失败"); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs new file mode 100644 index 0000000..169ddbb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CheckTenantQuotaCommandHandler.cs @@ -0,0 +1,130 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 配额校验处理器。 +/// +public sealed class CheckTenantQuotaCommandHandler( + ITenantRepository tenantRepository, + ITenantPackageRepository packageRepository, + ITenantQuotaUsageRepository quotaUsageRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(CheckTenantQuotaCommand request, CancellationToken cancellationToken) + { + // 1. 校验请求参数 + if (request.Delta <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "配额消耗量必须大于 0"); + } + + // 2. 校验租户上下文 + var currentTenantId = tenantProvider.GetCurrentTenantId(); + if (currentTenantId == 0 || currentTenantId != request.TenantId) + { + throw new BusinessException(ErrorCodes.Forbidden, "租户上下文不匹配,请在请求头 X-Tenant-Id 指定目标租户"); + } + + // 3. 获取租户与当前订阅 + _ = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + if (subscription == null || subscription.EffectiveTo <= DateTime.UtcNow) + { + throw new BusinessException(ErrorCodes.Conflict, "订阅不存在或已到期"); + } + + var package = await packageRepository.FindByIdAsync(subscription.TenantPackageId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "套餐不存在"); + + var limit = ResolveLimit(package, request.QuotaType); + + // 4. 加载配额使用记录并计算 + var usage = await quotaUsageRepository.FindAsync(request.TenantId, request.QuotaType, cancellationToken) + ?? new TenantQuotaUsage + { + TenantId = request.TenantId, + QuotaType = request.QuotaType, + LimitValue = limit ?? 0, + UsedValue = 0, + ResetCycle = ResolveResetCycle(request.QuotaType) + }; + + var usedAfter = usage.UsedValue + request.Delta; + if (limit.HasValue && usedAfter > (decimal)limit.Value) + { + usage.LimitValue = limit.Value; + await PersistUsageAsync(usage, quotaUsageRepository, cancellationToken); + throw new BusinessException(ErrorCodes.Conflict, $"{request.QuotaType} 配额不足"); + } + + // 5. 更新使用并保存 + usage.LimitValue = limit ?? usage.LimitValue; + usage.UsedValue = usedAfter; + usage.ResetCycle ??= ResolveResetCycle(request.QuotaType); + + await PersistUsageAsync(usage, quotaUsageRepository, cancellationToken); + + // 6. 返回结果 + return new QuotaCheckResultDto + { + QuotaType = request.QuotaType, + Limit = limit, + Used = usage.UsedValue, + Remaining = limit.HasValue ? limit.Value - usage.UsedValue : null + }; + } + + private static decimal? ResolveLimit(TenantPackage package, TenantQuotaType quotaType) + { + return quotaType switch + { + TenantQuotaType.StoreCount => package.MaxStoreCount, + TenantQuotaType.AccountCount => package.MaxAccountCount, + TenantQuotaType.Storage => package.MaxStorageGb, + TenantQuotaType.SmsCredits => package.MaxSmsCredits, + TenantQuotaType.DeliveryOrders => package.MaxDeliveryOrders, + _ => null + }; + } + + private static string ResolveResetCycle(TenantQuotaType quotaType) + { + return quotaType switch + { + TenantQuotaType.SmsCredits => "monthly", + TenantQuotaType.DeliveryOrders => "monthly", + _ => "lifetime" + }; + } + + private static async Task PersistUsageAsync( + TenantQuotaUsage usage, + ITenantQuotaUsageRepository quotaUsageRepository, + CancellationToken cancellationToken) + { + // 判断是否为新增。 + if (usage.Id == 0) + { + await quotaUsageRepository.AddAsync(usage, cancellationToken); + } + else + { + await quotaUsageRepository.UpdateAsync(usage, cancellationToken); + } + + await quotaUsageRepository.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs new file mode 100644 index 0000000..4894acb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantAnnouncementCommandHandler.cs @@ -0,0 +1,44 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 创建公告处理器。 +/// +public sealed class CreateTenantAnnouncementCommandHandler(ITenantAnnouncementRepository announcementRepository) + : IRequestHandler +{ + public async Task Handle(CreateTenantAnnouncementCommand request, CancellationToken cancellationToken) + { + // 1. 校验标题与内容 + if (string.IsNullOrWhiteSpace(request.Title) || string.IsNullOrWhiteSpace(request.Content)) + { + throw new BusinessException(ErrorCodes.BadRequest, "公告标题和内容不能为空"); + } + + // 2. 构建公告实体 + var announcement = new TenantAnnouncement + { + TenantId = request.TenantId, + Title = request.Title.Trim(), + Content = request.Content, + AnnouncementType = request.AnnouncementType, + Priority = request.Priority, + EffectiveFrom = request.EffectiveFrom, + EffectiveTo = request.EffectiveTo, + IsActive = request.IsActive + }; + + // 3. 持久化并返回 DTO + await announcementRepository.AddAsync(announcement, cancellationToken); + await announcementRepository.SaveChangesAsync(cancellationToken); + + return announcement.ToDto(false, null); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs new file mode 100644 index 0000000..42b629c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantBillingCommandHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 创建租户账单处理器。 +/// +public sealed class CreateTenantBillingCommandHandler(ITenantBillingRepository billingRepository) + : IRequestHandler +{ + public async Task Handle(CreateTenantBillingCommand request, CancellationToken cancellationToken) + { + // 1. 校验账单编号 + if (string.IsNullOrWhiteSpace(request.StatementNo)) + { + throw new BusinessException(ErrorCodes.BadRequest, "账单编号不能为空"); + } + + // 2. 构建账单实体 + var bill = new TenantBillingStatement + { + TenantId = request.TenantId, + StatementNo = request.StatementNo.Trim(), + PeriodStart = request.PeriodStart, + PeriodEnd = request.PeriodEnd, + AmountDue = request.AmountDue, + AmountPaid = request.AmountPaid, + Status = request.Status, + DueDate = request.DueDate, + LineItemsJson = request.LineItemsJson + }; + + // 3. 持久化账单 + await billingRepository.AddAsync(bill, cancellationToken); + await billingRepository.SaveChangesAsync(cancellationToken); + + // 4. 返回 DTO + return bill.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs new file mode 100644 index 0000000..c14a993 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantPackageCommandHandler.cs @@ -0,0 +1,49 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 创建租户套餐处理器。 +/// +public sealed class CreateTenantPackageCommandHandler(ITenantPackageRepository packageRepository) + : IRequestHandler +{ + /// + public async Task Handle(CreateTenantPackageCommand request, CancellationToken cancellationToken) + { + // 1. 校验套餐名称 + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new BusinessException(ErrorCodes.BadRequest, "套餐名称不能为空"); + } + + // 2. 构建套餐实体 + var package = new TenantPackage + { + Name = request.Name.Trim(), + Description = request.Description, + PackageType = request.PackageType, + MonthlyPrice = request.MonthlyPrice, + YearlyPrice = request.YearlyPrice, + MaxStoreCount = request.MaxStoreCount, + MaxAccountCount = request.MaxAccountCount, + MaxStorageGb = request.MaxStorageGb, + MaxSmsCredits = request.MaxSmsCredits, + MaxDeliveryOrders = request.MaxDeliveryOrders, + FeaturePoliciesJson = request.FeaturePoliciesJson, + IsActive = request.IsActive + }; + + // 3. 持久化并返回 + await packageRepository.AddAsync(package, cancellationToken); + await packageRepository.SaveChangesAsync(cancellationToken); + + return package.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs new file mode 100644 index 0000000..4d59ffe --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantSubscriptionCommandHandler.cs @@ -0,0 +1,86 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 新建/续费订阅处理器。 +/// +public sealed class CreateTenantSubscriptionCommandHandler( + ITenantRepository tenantRepository, + IIdGenerator idGenerator) + : IRequestHandler +{ + /// + public async Task Handle(CreateTenantSubscriptionCommand request, CancellationToken cancellationToken) + { + // 1. 校验订阅时长 + if (request.DurationMonths <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "订阅时长必须大于 0"); + } + + // 2. 获取租户与当前订阅 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var current = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + var from = current?.EffectiveTo ?? tenant.EffectiveTo ?? DateTime.UtcNow; + var effectiveFrom = from > DateTime.UtcNow ? from : DateTime.UtcNow; + var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths); + + // 3. 创建订阅实体 + var subscription = new TenantSubscription + { + Id = idGenerator.NextId(), + TenantId = tenant.Id, + TenantPackageId = request.TenantPackageId, + EffectiveFrom = effectiveFrom, + EffectiveTo = effectiveTo, + NextBillingDate = effectiveTo, + Status = SubscriptionStatus.Active, + AutoRenew = request.AutoRenew, + Notes = request.Notes + }; + + // 4. 记录订阅与历史 + await tenantRepository.AddSubscriptionAsync(subscription, cancellationToken); + await tenantRepository.AddSubscriptionHistoryAsync(new TenantSubscriptionHistory + { + Id = idGenerator.NextId(), + TenantId = tenant.Id, + TenantSubscriptionId = subscription.Id, + FromPackageId = current?.TenantPackageId ?? request.TenantPackageId, + ToPackageId = request.TenantPackageId, + ChangeType = current == null ? SubscriptionChangeType.New : SubscriptionChangeType.Renew, + EffectiveFrom = effectiveFrom, + EffectiveTo = effectiveTo, + Amount = null, + Currency = null, + Notes = request.Notes + }, cancellationToken); + + // 5. 记录审计 + await tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + TenantId = tenant.Id, + Action = TenantAuditAction.SubscriptionUpdated, + Title = current == null ? "创建订阅" : "续费订阅", + Description = $"套餐 {request.TenantPackageId} 时长 {request.DurationMonths} 月" + }, cancellationToken); + + // 6. 保存变更 + await tenantRepository.SaveChangesAsync(cancellationToken); + + // 7. 返回 DTO + return subscription.ToSubscriptionDto() + ?? throw new BusinessException(ErrorCodes.InternalServerError, "订阅生成失败"); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs new file mode 100644 index 0000000..d479dfd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantAnnouncementCommandHandler.cs @@ -0,0 +1,22 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 删除公告处理器。 +/// +public sealed class DeleteTenantAnnouncementCommandHandler(ITenantAnnouncementRepository announcementRepository) + : IRequestHandler +{ + public async Task Handle(DeleteTenantAnnouncementCommand request, CancellationToken cancellationToken) + { + // 1. 删除公告 + await announcementRepository.DeleteAsync(request.TenantId, request.AnnouncementId, cancellationToken); + await announcementRepository.SaveChangesAsync(cancellationToken); + + // 2. 返回执行结果 + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs new file mode 100644 index 0000000..c0f6fb4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/DeleteTenantPackageCommandHandler.cs @@ -0,0 +1,23 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 删除租户套餐处理器。 +/// +public sealed class DeleteTenantPackageCommandHandler(ITenantPackageRepository packageRepository) + : IRequestHandler +{ + /// + public async Task Handle(DeleteTenantPackageCommand request, CancellationToken cancellationToken) + { + // 1. 删除套餐 + await packageRepository.DeleteAsync(request.TenantPackageId, cancellationToken); + await packageRepository.SaveChangesAsync(cancellationToken); + + // 2. 返回执行结果 + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs new file mode 100644 index 0000000..90fa4b1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAnnouncementQueryHandler.cs @@ -0,0 +1,46 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 公告详情查询处理器。 +/// +public sealed class GetTenantAnnouncementQueryHandler( + ITenantAnnouncementRepository announcementRepository, + ITenantAnnouncementReadRepository readRepository, + ICurrentUserAccessor? currentUserAccessor = null) + : IRequestHandler +{ + public async Task Handle(GetTenantAnnouncementQuery request, CancellationToken cancellationToken) + { + // 1. 查询公告主体 + var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); + if (announcement == null) + { + return null; + } + + // 2. 优先查用户级已读 + var userId = currentUserAccessor?.UserId ?? 0; + var reads = await readRepository.GetByAnnouncementAsync( + request.TenantId, + new[] { request.AnnouncementId }, + userId == 0 ? null : userId, + cancellationToken); + + // 如无用户级已读,再查租户级已读 + if (reads.Count == 0) + { + var tenantReads = await readRepository.GetByAnnouncementAsync(request.TenantId, new[] { request.AnnouncementId }, null, cancellationToken); + reads = tenantReads; + } + + // 3. 返回 DTO 并附带已读状态 + var readRecord = reads.FirstOrDefault(); + return announcement.ToDto(readRecord != null, readRecord?.ReadAt); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs new file mode 100644 index 0000000..76aafb6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantAuditLogsQueryHandler.cs @@ -0,0 +1,32 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 审核日志查询。 +/// +public sealed class GetTenantAuditLogsQueryHandler(ITenantRepository tenantRepository) + : IRequestHandler> +{ + /// + public async Task> Handle(GetTenantAuditLogsQuery request, CancellationToken cancellationToken) + { + // 1. 查询审核日志 + var logs = await tenantRepository.GetAuditLogsAsync(request.TenantId, cancellationToken); + var total = logs.Count; + + // 2. 分页映射 + var paged = logs + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .Select(TenantMapping.ToDto) + .ToList(); + + // 3. 返回分页结果 + return new PagedResult(paged, request.Page, request.PageSize, total); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs new file mode 100644 index 0000000..5dbd0e2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantBillQueryHandler.cs @@ -0,0 +1,22 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 账单详情查询处理器。 +/// +public sealed class GetTenantBillQueryHandler(ITenantBillingRepository billingRepository) + : IRequestHandler +{ + public async Task Handle(GetTenantBillQuery request, CancellationToken cancellationToken) + { + // 1. 查询账单 + var bill = await billingRepository.FindByIdAsync(request.TenantId, request.BillingId, cancellationToken); + + // 2. 返回 DTO 或 null + return bill?.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs new file mode 100644 index 0000000..c4f89f9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantByIdQueryHandler.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 租户详情查询处理器。 +/// +public sealed class GetTenantByIdQueryHandler(ITenantRepository tenantRepository) + : IRequestHandler +{ + /// + public async Task Handle(GetTenantByIdQuery request, CancellationToken cancellationToken) + { + // 1. 查询租户 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + // 2. 查询订阅与认证 + var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + var verification = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken); + + // 3. 组装返回 + return new TenantDetailDto + { + Tenant = TenantMapping.ToDto(tenant, subscription, verification), + Verification = verification.ToVerificationDto(), + Subscription = subscription.ToSubscriptionDto() + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs new file mode 100644 index 0000000..4b6b898 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/GetTenantPackageByIdQueryHandler.cs @@ -0,0 +1,23 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 套餐详情查询处理器。 +/// +public sealed class GetTenantPackageByIdQueryHandler(ITenantPackageRepository packageRepository) + : IRequestHandler +{ + /// + public async Task Handle(GetTenantPackageByIdQuery request, CancellationToken cancellationToken) + { + // 1. 查询套餐 + var package = await packageRepository.FindByIdAsync(request.TenantPackageId, cancellationToken); + + // 2. 返回 DTO 或 null + return package?.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs new file mode 100644 index 0000000..1600ecf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantAnnouncementReadCommandHandler.cs @@ -0,0 +1,51 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 标记公告已读处理器。 +/// +public sealed class MarkTenantAnnouncementReadCommandHandler( + ITenantAnnouncementRepository announcementRepository, + ITenantAnnouncementReadRepository readRepository, + ICurrentUserAccessor? currentUserAccessor = null) + : IRequestHandler +{ + public async Task Handle(MarkTenantAnnouncementReadCommand request, CancellationToken cancellationToken) + { + // 1. 查询公告 + var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); + if (announcement == null) + { + return null; + } + + // 2. 确定用户标识 + var userId = currentUserAccessor?.UserId ?? 0; + var existing = await readRepository.FindAsync(request.TenantId, request.AnnouncementId, userId == 0 ? null : userId, cancellationToken); + + // 3. 如未读则写入已读记录 + if (existing == null) + { + var record = new TenantAnnouncementRead + { + TenantId = request.TenantId, + AnnouncementId = request.AnnouncementId, + UserId = userId == 0 ? null : userId, + ReadAt = DateTime.UtcNow + }; + + await readRepository.AddAsync(record, cancellationToken); + await readRepository.SaveChangesAsync(cancellationToken); + existing = record; + } + + // 4. 返回带已读时间的公告 DTO + return announcement.ToDto(true, existing.ReadAt); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs new file mode 100644 index 0000000..6727959 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantBillingPaidCommandHandler.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 标记账单支付处理器。 +/// +public sealed class MarkTenantBillingPaidCommandHandler(ITenantBillingRepository billingRepository) + : IRequestHandler +{ + public async Task Handle(MarkTenantBillingPaidCommand request, CancellationToken cancellationToken) + { + // 1. 查询账单 + var bill = await billingRepository.FindByIdAsync(request.TenantId, request.BillingId, cancellationToken); + if (bill == null) + { + return null; + } + + // 2. 更新支付状态 + bill.AmountPaid = request.AmountPaid; + bill.Status = TenantBillingStatus.Paid; + bill.DueDate = bill.DueDate; + + // 3. 持久化变更 + await billingRepository.UpdateAsync(bill, cancellationToken); + await billingRepository.SaveChangesAsync(cancellationToken); + + // 4. 返回 DTO + return bill.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs new file mode 100644 index 0000000..1d91e66 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/MarkTenantNotificationReadCommandHandler.cs @@ -0,0 +1,34 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 标记通知已读处理器。 +/// +public sealed class MarkTenantNotificationReadCommandHandler(ITenantNotificationRepository notificationRepository) + : IRequestHandler +{ + public async Task Handle(MarkTenantNotificationReadCommand request, CancellationToken cancellationToken) + { + // 1. 查询通知 + var notification = await notificationRepository.FindByIdAsync(request.TenantId, request.NotificationId, cancellationToken); + if (notification == null) + { + return null; + } + + // 2. 若未读则标记已读 + if (notification.ReadAt == null) + { + notification.ReadAt = DateTime.UtcNow; + await notificationRepository.UpdateAsync(notification, cancellationToken); + await notificationRepository.SaveChangesAsync(cancellationToken); + } + + // 3. 返回 DTO + return notification.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs new file mode 100644 index 0000000..e236ab1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/RegisterTenantCommandHandler.cs @@ -0,0 +1,92 @@ +using MediatR; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 租户注册处理器。 +/// +public sealed class RegisterTenantCommandHandler( + ITenantRepository tenantRepository, + IIdGenerator idGenerator, + ILogger logger) + : IRequestHandler +{ + /// + public async Task Handle(RegisterTenantCommand request, CancellationToken cancellationToken) + { + // 1. 校验订阅时长 + if (request.DurationMonths <= 0) + { + throw new BusinessException(ErrorCodes.BadRequest, "订阅时长必须大于 0"); + } + + // 2. 检查租户编码唯一性 + if (await tenantRepository.ExistsByCodeAsync(request.Code, cancellationToken)) + { + throw new BusinessException(ErrorCodes.Conflict, $"租户编码 {request.Code} 已存在"); + } + + // 3. 计算生效时间 + var now = DateTime.UtcNow; + var effectiveFrom = request.EffectiveFrom ?? now; + var effectiveTo = effectiveFrom.AddMonths(request.DurationMonths); + + // 4. 构建租户实体 + var tenant = new Tenant + { + Id = idGenerator.NextId(), + Code = request.Code.Trim(), + Name = request.Name, + ShortName = request.ShortName, + Industry = request.Industry, + ContactName = request.ContactName, + ContactPhone = request.ContactPhone, + ContactEmail = request.ContactEmail, + Status = TenantStatus.PendingReview, + EffectiveFrom = effectiveFrom, + EffectiveTo = effectiveTo + }; + + // 5. 构建订阅实体 + var subscription = new TenantSubscription + { + Id = idGenerator.NextId(), + TenantId = tenant.Id, + TenantPackageId = request.TenantPackageId, + EffectiveFrom = effectiveFrom, + EffectiveTo = effectiveTo, + NextBillingDate = effectiveTo, + Status = SubscriptionStatus.Pending, + AutoRenew = request.AutoRenew, + Notes = "Init subscription" + }; + + // 6. 持久化租户、订阅和审计日志 + await tenantRepository.AddTenantAsync(tenant, cancellationToken); + await tenantRepository.AddSubscriptionAsync(subscription, cancellationToken); + await tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + TenantId = tenant.Id, + Action = TenantAuditAction.RegistrationSubmitted, + Title = "租户注册", + Description = $"提交套餐 {request.TenantPackageId},时长 {request.DurationMonths} 月" + }, cancellationToken); + + await tenantRepository.SaveChangesAsync(cancellationToken); + + // 7. 记录日志 + logger.LogInformation("已注册租户 {TenantCode}", tenant.Code); + + // 8. 返回 DTO + return TenantMapping.ToDto(tenant, subscription, null); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs new file mode 100644 index 0000000..022d1e1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/ReviewTenantCommandHandler.cs @@ -0,0 +1,91 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 租户审核处理器。 +/// +public sealed class ReviewTenantCommandHandler( + ITenantRepository tenantRepository, + ICurrentUserAccessor currentUserAccessor) + : IRequestHandler +{ + /// + public async Task Handle(ReviewTenantCommand request, CancellationToken cancellationToken) + { + // 1. 获取租户与认证资料 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + var verification = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.BadRequest, "请先提交实名认证资料"); + + var subscription = await tenantRepository.GetActiveSubscriptionAsync(request.TenantId, cancellationToken); + + // 2. 记录审核人 + var actorName = currentUserAccessor.IsAuthenticated + ? $"user:{currentUserAccessor.UserId}" + : "system"; + + // 3. 写入审核信息 + verification.ReviewedAt = DateTime.UtcNow; + verification.ReviewedBy = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId; + verification.ReviewedByName = actorName; + verification.ReviewRemarks = request.Reason; + + var previousStatus = tenant.Status; + + // 4. 更新租户与订阅状态 + if (request.Approve) + { + verification.Status = TenantVerificationStatus.Approved; + tenant.Status = TenantStatus.Active; + if (subscription != null) + { + subscription.Status = SubscriptionStatus.Active; + } + } + else + { + verification.Status = TenantVerificationStatus.Rejected; + tenant.Status = TenantStatus.PendingReview; + if (subscription != null) + { + subscription.Status = SubscriptionStatus.Suspended; + } + } + + // 5. 持久化租户与认证资料 + await tenantRepository.UpdateTenantAsync(tenant, cancellationToken); + await tenantRepository.UpsertVerificationProfileAsync(verification, cancellationToken); + if (subscription != null) + { + await tenantRepository.UpdateSubscriptionAsync(subscription, cancellationToken); + } + + // 6. 记录审核日志 + await tenantRepository.AddAuditLogAsync(new Domain.Tenants.Entities.TenantAuditLog + { + TenantId = tenant.Id, + Action = request.Approve ? TenantAuditAction.VerificationApproved : TenantAuditAction.VerificationRejected, + Title = request.Approve ? "审核通过" : "审核驳回", + Description = request.Reason, + OperatorId = currentUserAccessor.UserId == 0 ? null : currentUserAccessor.UserId, + OperatorName = actorName, + PreviousStatus = previousStatus, + CurrentStatus = tenant.Status + }, cancellationToken); + + // 7. 保存并返回 DTO + await tenantRepository.SaveChangesAsync(cancellationToken); + + return TenantMapping.ToDto(tenant, subscription, verification); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs new file mode 100644 index 0000000..2c15546 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantAnnouncementsQueryHandler.cs @@ -0,0 +1,83 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 公告分页查询处理器。 +/// +public sealed class SearchTenantAnnouncementsQueryHandler( + ITenantAnnouncementRepository announcementRepository, + ITenantAnnouncementReadRepository announcementReadRepository, + ICurrentUserAccessor? currentUserAccessor = null) + : IRequestHandler> +{ + public async Task> Handle(SearchTenantAnnouncementsQuery request, CancellationToken cancellationToken) + { + // 1. 过滤有效期条件 + var effectiveAt = request.OnlyEffective == true ? DateTime.UtcNow : (DateTime?)null; + var announcements = await announcementRepository.SearchAsync(request.TenantId, request.AnnouncementType, request.IsActive, effectiveAt, cancellationToken); + + // 2. 排序(优先级/时间) + var ordered = announcements + .OrderByDescending(x => x.Priority) + .ThenByDescending(x => x.CreatedAt) + .ToList(); + + // 3. 计算分页参数 + var page = request.Page <= 0 ? 1 : request.Page; + var size = request.PageSize <= 0 ? 20 : request.PageSize; + + // 4. 分页 + var pageItems = ordered + .Skip((page - 1) * size) + .Take(size) + .ToList(); + + // 5. 构建已读映射 + var announcementIds = pageItems.Select(x => x.Id).ToArray(); + var userId = currentUserAccessor?.UserId ?? 0; + + var readMap = new Dictionary(); + if (announcementIds.Length > 0) + { + // 优先查询当前用户维度的已读,其次租户级已读(UserId null) + var reads = new List(); + if (userId != 0) + { + var userReads = await announcementReadRepository.GetByAnnouncementAsync(request.TenantId, announcementIds, userId, cancellationToken); + reads.AddRange(userReads); + } + + var tenantReads = await announcementReadRepository.GetByAnnouncementAsync(request.TenantId, announcementIds, null, cancellationToken); + reads.AddRange(tenantReads); + + foreach (var read in reads.OrderByDescending(x => x.ReadAt)) + { + // 若已存在用户级标记,跳过租户级覆盖 + if (readMap.ContainsKey(read.AnnouncementId) && read.UserId.HasValue) + { + continue; + } + + readMap[read.AnnouncementId] = (true, read.ReadAt); + } + } + + // 6. 映射 DTO 并带上已读状态 + var items = pageItems + .Select(a => + { + readMap.TryGetValue(a.Id, out var read); + return a.ToDto(read.isRead, read.readAt); + }) + .ToList(); + + // 7. 返回分页结果 + return new PagedResult(items, page, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs new file mode 100644 index 0000000..66821fc --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantBillsQueryHandler.cs @@ -0,0 +1,29 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 账单分页查询处理器。 +/// +public sealed class SearchTenantBillsQueryHandler(ITenantBillingRepository billingRepository) + : IRequestHandler> +{ + public async Task> Handle(SearchTenantBillsQuery request, CancellationToken cancellationToken) + { + // 1. 查询账单 + var bills = await billingRepository.SearchAsync(request.TenantId, request.Status, request.From, request.To, cancellationToken); + + // 2. 排序与分页 + var ordered = bills.OrderByDescending(x => x.PeriodEnd).ToList(); + var page = request.Page <= 0 ? 1 : request.Page; + var size = request.PageSize <= 0 ? 20 : request.PageSize; + var items = ordered.Skip((page - 1) * size).Take(size).Select(x => x.ToDto()).ToList(); + + // 3. 返回分页结果 + return new PagedResult(items, page, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs new file mode 100644 index 0000000..294a639 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantNotificationsQueryHandler.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 通知分页查询处理器。 +/// +public sealed class SearchTenantNotificationsQueryHandler(ITenantNotificationRepository notificationRepository) + : IRequestHandler> +{ + public async Task> Handle(SearchTenantNotificationsQuery request, CancellationToken cancellationToken) + { + // 1. 查询通知 + var notifications = await notificationRepository.SearchAsync( + request.TenantId, + request.Severity, + request.UnreadOnly, + null, + null, + cancellationToken); + + // 2. 排序与分页 + var ordered = notifications.OrderByDescending(x => x.SentAt).ToList(); + var page = request.Page <= 0 ? 1 : request.Page; + var size = request.PageSize <= 0 ? 20 : request.PageSize; + var items = ordered.Skip((page - 1) * size).Take(size).Select(x => x.ToDto()).ToList(); + + // 3. 返回分页结果 + return new PagedResult(items, page, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs new file mode 100644 index 0000000..21c3ba7 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantPackagesQueryHandler.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 套餐分页查询处理器。 +/// +public sealed class SearchTenantPackagesQueryHandler(ITenantPackageRepository packageRepository) + : IRequestHandler> +{ + /// + public async Task> Handle(SearchTenantPackagesQuery request, CancellationToken cancellationToken) + { + // 1. 查询套餐 + var packages = await packageRepository.SearchAsync(request.Keyword, request.IsActive, cancellationToken); + + // 2. 排序与分页 + var ordered = packages.OrderByDescending(x => x.CreatedAt).ToList(); + var pageIndex = request.Page <= 0 ? 1 : request.Page; + var size = request.PageSize <= 0 ? 20 : request.PageSize; + + var pagedItems = ordered + .Skip((pageIndex - 1) * size) + .Take(size) + .Select(x => x.ToDto()) + .ToList(); + + // 3. 返回分页结果 + return new PagedResult(pagedItems, pageIndex, size, ordered.Count); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs new file mode 100644 index 0000000..755b911 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SearchTenantsQueryHandler.cs @@ -0,0 +1,40 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Application.App.Tenants.Queries; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 租户分页查询处理器。 +/// +public sealed class SearchTenantsQueryHandler(ITenantRepository tenantRepository) + : IRequestHandler> +{ + /// + public async Task> Handle(SearchTenantsQuery request, CancellationToken cancellationToken) + { + // 1. 查询租户列表 + var tenants = await tenantRepository.SearchAsync(request.Status, request.Keyword, cancellationToken); + var total = tenants.Count; + + // 2. 分页 + var paged = tenants + .Skip((request.Page - 1) * request.PageSize) + .Take(request.PageSize) + .ToList(); + + // 3. 映射 DTO(带订阅与认证) + var result = new List(paged.Count); + foreach (var tenant in paged) + { + var subscription = await tenantRepository.GetActiveSubscriptionAsync(tenant.Id, cancellationToken); + var verification = await tenantRepository.GetVerificationProfileAsync(tenant.Id, cancellationToken); + result.Add(TenantMapping.ToDto(tenant, subscription, verification)); + } + + // 4. 返回分页结果 + return new PagedResult(result, request.Page, request.PageSize, total); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs new file mode 100644 index 0000000..8771c72 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SubmitTenantVerificationCommandHandler.cs @@ -0,0 +1,65 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 实名资料提交流程。 +/// +public sealed class SubmitTenantVerificationCommandHandler( + ITenantRepository tenantRepository, + IIdGenerator idGenerator) + : IRequestHandler +{ + /// + public async Task Handle(SubmitTenantVerificationCommand request, CancellationToken cancellationToken) + { + // 1. 获取租户 + var tenant = await tenantRepository.FindByIdAsync(request.TenantId, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, "租户不存在"); + + // 2. 读取或初始化实名资料 + var profile = await tenantRepository.GetVerificationProfileAsync(request.TenantId, cancellationToken) + ?? new TenantVerificationProfile { Id = idGenerator.NextId(), TenantId = tenant.Id }; + + // 3. 填充资料 + profile.BusinessLicenseNumber = request.BusinessLicenseNumber; + profile.BusinessLicenseUrl = request.BusinessLicenseUrl; + profile.LegalPersonName = request.LegalPersonName; + profile.LegalPersonIdNumber = request.LegalPersonIdNumber; + profile.LegalPersonIdFrontUrl = request.LegalPersonIdFrontUrl; + profile.LegalPersonIdBackUrl = request.LegalPersonIdBackUrl; + profile.BankAccountName = request.BankAccountName; + profile.BankAccountNumber = request.BankAccountNumber; + profile.BankName = request.BankName; + profile.AdditionalDataJson = request.AdditionalDataJson; + profile.Status = TenantVerificationStatus.Pending; + profile.SubmittedAt = DateTime.UtcNow; + profile.ReviewedAt = null; + profile.ReviewRemarks = null; + profile.ReviewedBy = null; + profile.ReviewedByName = null; + + // 4. 保存资料并记录审计 + await tenantRepository.UpsertVerificationProfileAsync(profile, cancellationToken); + await tenantRepository.AddAuditLogAsync(new TenantAuditLog + { + TenantId = tenant.Id, + Action = TenantAuditAction.VerificationSubmitted, + Title = "提交实名认证资料", + Description = request.BusinessLicenseNumber + }, cancellationToken); + await tenantRepository.SaveChangesAsync(cancellationToken); + + // 5. 返回 DTO + return profile.ToVerificationDto() + ?? throw new BusinessException(ErrorCodes.InternalServerError, "实名资料保存失败"); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs new file mode 100644 index 0000000..c802631 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantAnnouncementCommandHandler.cs @@ -0,0 +1,47 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 更新公告处理器。 +/// +public sealed class UpdateTenantAnnouncementCommandHandler(ITenantAnnouncementRepository announcementRepository) + : IRequestHandler +{ + public async Task Handle(UpdateTenantAnnouncementCommand request, CancellationToken cancellationToken) + { + // 1. 校验输入 + if (string.IsNullOrWhiteSpace(request.Title) || string.IsNullOrWhiteSpace(request.Content)) + { + throw new BusinessException(ErrorCodes.BadRequest, "公告标题和内容不能为空"); + } + + // 2. 查询公告 + var announcement = await announcementRepository.FindByIdAsync(request.TenantId, request.AnnouncementId, cancellationToken); + if (announcement == null) + { + return null; + } + + // 3. 更新字段 + announcement.Title = request.Title.Trim(); + announcement.Content = request.Content; + announcement.AnnouncementType = request.AnnouncementType; + announcement.Priority = request.Priority; + announcement.EffectiveFrom = request.EffectiveFrom; + announcement.EffectiveTo = request.EffectiveTo; + announcement.IsActive = request.IsActive; + + // 4. 持久化 + await announcementRepository.UpdateAsync(announcement, cancellationToken); + await announcementRepository.SaveChangesAsync(cancellationToken); + + // 5. 返回 DTO + return announcement.ToDto(false, null); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs new file mode 100644 index 0000000..7821bbf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/UpdateTenantPackageCommandHandler.cs @@ -0,0 +1,52 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Commands; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.App.Tenants.Handlers; + +/// +/// 更新租户套餐处理器。 +/// +public sealed class UpdateTenantPackageCommandHandler(ITenantPackageRepository packageRepository) + : IRequestHandler +{ + /// + public async Task Handle(UpdateTenantPackageCommand request, CancellationToken cancellationToken) + { + // 1. 校验必填项 + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new BusinessException(ErrorCodes.BadRequest, "套餐名称不能为空"); + } + + // 2. 查询套餐 + var package = await packageRepository.FindByIdAsync(request.TenantPackageId, cancellationToken); + if (package == null) + { + return null; + } + + // 3. 更新字段 + package.Name = request.Name.Trim(); + package.Description = request.Description; + package.PackageType = request.PackageType; + package.MonthlyPrice = request.MonthlyPrice; + package.YearlyPrice = request.YearlyPrice; + package.MaxStoreCount = request.MaxStoreCount; + package.MaxAccountCount = request.MaxAccountCount; + package.MaxStorageGb = request.MaxStorageGb; + package.MaxSmsCredits = request.MaxSmsCredits; + package.MaxDeliveryOrders = request.MaxDeliveryOrders; + package.FeaturePoliciesJson = request.FeaturePoliciesJson; + package.IsActive = request.IsActive; + + // 4. 持久化并返回 + await packageRepository.UpdateAsync(package, cancellationToken); + await packageRepository.SaveChangesAsync(cancellationToken); + + return package.ToDto(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs new file mode 100644 index 0000000..74a28b3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAnnouncementQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 公告详情查询。 +/// +public sealed record GetTenantAnnouncementQuery : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 公告 ID。 + /// + public long AnnouncementId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAuditLogsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAuditLogsQuery.cs new file mode 100644 index 0000000..10aa010 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantAuditLogsQuery.cs @@ -0,0 +1,13 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 租户审核日志查询。 +/// +public sealed record GetTenantAuditLogsQuery( + long TenantId, + int Page = 1, + int PageSize = 20) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs new file mode 100644 index 0000000..a5ece15 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantBillQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 获取账单详情查询。 +/// +public sealed record GetTenantBillQuery : IRequest +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 账单 ID。 + /// + public long BillingId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantByIdQuery.cs new file mode 100644 index 0000000..43e226d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantByIdQuery.cs @@ -0,0 +1,9 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 单个租户查询。 +/// +public sealed record GetTenantByIdQuery(long TenantId) : IRequest; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageByIdQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageByIdQuery.cs new file mode 100644 index 0000000..252b5fe --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/GetTenantPackageByIdQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 获取套餐详情查询。 +/// +public sealed record GetTenantPackageByIdQuery : IRequest +{ + /// + /// 套餐 ID。 + /// + public long TenantPackageId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs new file mode 100644 index 0000000..140acb1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantAnnouncementsQuery.cs @@ -0,0 +1,42 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 分页查询租户公告。 +/// +public sealed record SearchTenantAnnouncementsQuery : IRequest> +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 公告类型筛选。 + /// + public TenantAnnouncementType? AnnouncementType { get; init; } + + /// + /// 是否筛选启用状态。 + /// + public bool? IsActive { get; init; } + + /// + /// 仅返回当前有效期内的公告。 + /// + public bool? OnlyEffective { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs new file mode 100644 index 0000000..67d0103 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantBillsQuery.cs @@ -0,0 +1,42 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 分页查询租户账单。 +/// +public sealed record SearchTenantBillsQuery : IRequest> +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 账单状态筛选。 + /// + public TenantBillingStatus? Status { get; init; } + + /// + /// 账单起始时间(UTC)筛选。 + /// + public DateTime? From { get; init; } + + /// + /// 账单结束时间(UTC)筛选。 + /// + public DateTime? To { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs new file mode 100644 index 0000000..ff5e8eb --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantNotificationsQuery.cs @@ -0,0 +1,37 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 分页查询租户通知。 +/// +public sealed record SearchTenantNotificationsQuery : IRequest> +{ + /// + /// 租户 ID(雪花算法)。 + /// + public long TenantId { get; init; } + + /// + /// 通知等级筛选。 + /// + public TenantNotificationSeverity? Severity { get; init; } + + /// + /// 仅返回未读通知。 + /// + public bool? UnreadOnly { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantPackagesQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantPackagesQuery.cs new file mode 100644 index 0000000..c81a068 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantPackagesQuery.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 分页查询租户套餐。 +/// +public sealed record SearchTenantPackagesQuery : IRequest> +{ + /// + /// 搜索关键词(名称/描述)。 + /// + public string? Keyword { get; init; } + + /// + /// 是否筛选可售套餐。 + /// + public bool? IsActive { get; init; } + + /// + /// 页码。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; +} diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs new file mode 100644 index 0000000..f3834d1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Queries/SearchTenantsQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Application.App.Tenants.Queries; + +/// +/// 租户分页查询。 +/// +public sealed record SearchTenantsQuery( + TenantStatus? Status, + string? Keyword, + int Page = 1, + int PageSize = 20) : IRequest>; diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs new file mode 100644 index 0000000..d361892 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/TenantMapping.cs @@ -0,0 +1,139 @@ +using TakeoutSaaS.Application.App.Tenants.Dto; +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Application.App.Tenants; + +/// +/// 租户 DTO 映射助手。 +/// +internal static class TenantMapping +{ + public static TenantDto ToDto(Tenant tenant, TenantSubscription? subscription, TenantVerificationProfile? verification) + => new() + { + Id = tenant.Id, + Code = tenant.Code, + Name = tenant.Name, + ShortName = tenant.ShortName, + ContactName = tenant.ContactName, + ContactPhone = tenant.ContactPhone, + ContactEmail = tenant.ContactEmail, + Status = tenant.Status, + VerificationStatus = verification?.Status ?? Domain.Tenants.Enums.TenantVerificationStatus.Draft, + CurrentPackageId = subscription?.TenantPackageId, + EffectiveFrom = subscription?.EffectiveFrom ?? tenant.EffectiveFrom, + EffectiveTo = subscription?.EffectiveTo ?? tenant.EffectiveTo, + AutoRenew = subscription?.AutoRenew ?? false + }; + + public static TenantVerificationDto? ToVerificationDto(this TenantVerificationProfile? profile) + => profile == null + ? null + : new TenantVerificationDto + { + Id = profile.Id, + TenantId = profile.TenantId, + Status = profile.Status, + BusinessLicenseNumber = profile.BusinessLicenseNumber, + BusinessLicenseUrl = profile.BusinessLicenseUrl, + LegalPersonName = profile.LegalPersonName, + LegalPersonIdNumber = profile.LegalPersonIdNumber, + BankAccountNumber = profile.BankAccountNumber, + BankName = profile.BankName, + ReviewRemarks = profile.ReviewRemarks, + ReviewedByName = profile.ReviewedByName, + ReviewedAt = profile.ReviewedAt + }; + + public static TenantSubscriptionDto? ToSubscriptionDto(this TenantSubscription? subscription) + => subscription == null + ? null + : new TenantSubscriptionDto + { + Id = subscription.Id, + TenantId = subscription.TenantId, + TenantPackageId = subscription.TenantPackageId, + Status = subscription.Status, + EffectiveFrom = subscription.EffectiveFrom, + EffectiveTo = subscription.EffectiveTo, + NextBillingDate = subscription.NextBillingDate, + AutoRenew = subscription.AutoRenew + }; + + public static TenantAuditLogDto ToDto(this TenantAuditLog log) + => new() + { + Id = log.Id, + TenantId = log.TenantId, + Action = log.Action, + Title = log.Title, + Description = log.Description, + OperatorName = log.OperatorName, + PreviousStatus = log.PreviousStatus, + CurrentStatus = log.CurrentStatus, + CreatedAt = log.CreatedAt + }; + + public static TenantPackageDto ToDto(this TenantPackage package) + => new() + { + Id = package.Id, + Name = package.Name, + Description = package.Description, + PackageType = package.PackageType, + MonthlyPrice = package.MonthlyPrice, + YearlyPrice = package.YearlyPrice, + MaxStoreCount = package.MaxStoreCount, + MaxAccountCount = package.MaxAccountCount, + MaxStorageGb = package.MaxStorageGb, + MaxSmsCredits = package.MaxSmsCredits, + MaxDeliveryOrders = package.MaxDeliveryOrders, + FeaturePoliciesJson = package.FeaturePoliciesJson, + IsActive = package.IsActive + }; + + public static TenantBillingDto ToDto(this TenantBillingStatement bill) + => new() + { + Id = bill.Id, + TenantId = bill.TenantId, + StatementNo = bill.StatementNo, + PeriodStart = bill.PeriodStart, + PeriodEnd = bill.PeriodEnd, + AmountDue = bill.AmountDue, + AmountPaid = bill.AmountPaid, + Status = bill.Status, + DueDate = bill.DueDate, + LineItemsJson = bill.LineItemsJson + }; + + public static TenantAnnouncementDto ToDto(this TenantAnnouncement announcement, bool isRead, DateTime? readAt) + => new() + { + Id = announcement.Id, + TenantId = announcement.TenantId, + Title = announcement.Title, + Content = announcement.Content, + AnnouncementType = announcement.AnnouncementType, + Priority = announcement.Priority, + EffectiveFrom = announcement.EffectiveFrom, + EffectiveTo = announcement.EffectiveTo, + IsActive = announcement.IsActive, + IsRead = isRead, + ReadAt = readAt + }; + + public static TenantNotificationDto ToDto(this TenantNotification notification) + => new() + { + Id = notification.Id, + TenantId = notification.TenantId, + Title = notification.Title, + Message = notification.Message, + Channel = notification.Channel, + Severity = notification.Severity, + SentAt = notification.SentAt, + ReadAt = notification.ReadAt, + MetadataJson = notification.MetadataJson + }; +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs index ebdc59f..f9ea54b 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Abstractions/IDictionaryCache.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Dictionary.Models; namespace TakeoutSaaS.Application.Dictionary.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs index 668d369..b19ccce 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Contracts/CreateDictionaryItemRequest.cs @@ -1,6 +1,6 @@ +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using TakeoutSaaS.Shared.Abstractions.Serialization; -using System.ComponentModel.DataAnnotations; namespace TakeoutSaaS.Application.Dictionary.Contracts; diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs index 95a81f0..cdee6bf 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/DictionaryGroupDto.cs @@ -1,6 +1,5 @@ -using TakeoutSaaS.Domain.Dictionary.Enums; - using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Dictionary.Enums; using TakeoutSaaS.Shared.Abstractions.Serialization; namespace TakeoutSaaS.Application.Dictionary.Models; diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs index 5ff8e9a..65616a8 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.Extensions.Logging; using TakeoutSaaS.Application.Dictionary.Abstractions; using TakeoutSaaS.Application.Dictionary.Contracts; @@ -21,18 +20,20 @@ public sealed class DictionaryAppService( ITenantProvider tenantProvider, ILogger logger) : IDictionaryAppService { - public async Task CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default) { + // 1. 规范化编码并确定租户 var normalizedCode = NormalizeCode(request.Code); var targetTenant = ResolveTargetTenant(request.Scope); + // 2. 校验编码唯一 var existing = await repository.FindGroupByCodeAsync(normalizedCode, cancellationToken); if (existing != null) { throw new BusinessException(ErrorCodes.Conflict, $"字典分组编码 {normalizedCode} 已存在"); } + // 3. 构建分组实体 var group = new DictionaryGroup { Id = 0, @@ -44,6 +45,7 @@ public sealed class DictionaryAppService( IsEnabled = true }; + // 4. 持久化并返回 await repository.AddGroupAsync(group, cancellationToken); await repository.SaveChangesAsync(cancellationToken); logger.LogInformation("创建字典分组:{Code}({Scope})", group.Code, group.Scope); @@ -52,13 +54,16 @@ public sealed class DictionaryAppService( public async Task UpdateGroupAsync(long groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default) { + // 1. 读取分组并校验权限 var group = await RequireGroupAsync(groupId, cancellationToken); EnsureScopePermission(group.Scope); + // 2. 更新字段 group.Name = request.Name.Trim(); group.Description = request.Description?.Trim(); group.IsEnabled = request.IsEnabled; + // 3. 持久化并失效缓存 await repository.SaveChangesAsync(cancellationToken); await InvalidateCacheAsync(group, cancellationToken); logger.LogInformation("更新字典分组:{GroupId}", group.Id); @@ -67,9 +72,11 @@ public sealed class DictionaryAppService( public async Task DeleteGroupAsync(long groupId, CancellationToken cancellationToken = default) { + // 1. 读取分组并校验权限 var group = await RequireGroupAsync(groupId, cancellationToken); EnsureScopePermission(group.Scope); + // 2. 删除并失效缓存 await repository.RemoveGroupAsync(group, cancellationToken); await repository.SaveChangesAsync(cancellationToken); await InvalidateCacheAsync(group, cancellationToken); @@ -78,10 +85,12 @@ public sealed class DictionaryAppService( public async Task> SearchGroupsAsync(DictionaryGroupQuery request, CancellationToken cancellationToken = default) { + // 1. 确定查询范围并校验权限 var tenantId = tenantProvider.GetCurrentTenantId(); var scope = ResolveScopeForQuery(request.Scope, tenantId); EnsureScopePermission(scope); + // 2. 查询分组及可选项 var groups = await repository.SearchGroupsAsync(scope, cancellationToken); var includeItems = request.IncludeItems; var result = new List(groups.Count); @@ -91,6 +100,7 @@ public sealed class DictionaryAppService( IReadOnlyList items = Array.Empty(); if (includeItems) { + // 查询分组下字典项 var itemEntities = await repository.GetItemsByGroupIdAsync(group.Id, cancellationToken); items = itemEntities.Select(MapItem).ToList(); } @@ -103,9 +113,11 @@ public sealed class DictionaryAppService( public async Task CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default) { + // 1. 校验分组与权限 var group = await RequireGroupAsync(request.GroupId, cancellationToken); EnsureScopePermission(group.Scope); + // 2. 构建字典项 var item = new DictionaryItem { Id = 0, @@ -119,6 +131,7 @@ public sealed class DictionaryAppService( IsEnabled = request.IsEnabled }; + // 3. 持久化并失效缓存 await repository.AddItemAsync(item, cancellationToken); await repository.SaveChangesAsync(cancellationToken); await InvalidateCacheAsync(group, cancellationToken); @@ -128,16 +141,19 @@ public sealed class DictionaryAppService( public async Task UpdateItemAsync(long itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default) { + // 1. 读取字典项与分组并校验权限 var item = await RequireItemAsync(itemId, cancellationToken); var group = await RequireGroupAsync(item.GroupId, cancellationToken); EnsureScopePermission(group.Scope); + // 2. 更新字段 item.Value = request.Value.Trim(); item.Description = request.Description?.Trim(); item.SortOrder = request.SortOrder; item.IsDefault = request.IsDefault; item.IsEnabled = request.IsEnabled; + // 3. 持久化并失效缓存 await repository.SaveChangesAsync(cancellationToken); await InvalidateCacheAsync(group, cancellationToken); logger.LogInformation("更新字典项:{ItemId}", item.Id); @@ -146,10 +162,12 @@ public sealed class DictionaryAppService( public async Task DeleteItemAsync(long itemId, CancellationToken cancellationToken = default) { + // 1. 读取字典项与分组并校验权限 var item = await RequireItemAsync(itemId, cancellationToken); var group = await RequireGroupAsync(item.GroupId, cancellationToken); EnsureScopePermission(group.Scope); + // 2. 删除并失效缓存 await repository.RemoveItemAsync(item, cancellationToken); await repository.SaveChangesAsync(cancellationToken); await InvalidateCacheAsync(group, cancellationToken); @@ -158,6 +176,7 @@ public sealed class DictionaryAppService( public async Task>> GetCachedItemsAsync(DictionaryBatchQueryRequest request, CancellationToken cancellationToken = default) { + // 1. 规范化编码 var normalizedCodes = request.Codes .Where(code => !string.IsNullOrWhiteSpace(code)) .Select(NormalizeCode) @@ -169,6 +188,7 @@ public sealed class DictionaryAppService( return new Dictionary>(StringComparer.OrdinalIgnoreCase); } + // 2. 按租户合并系统与业务字典 var tenantId = tenantProvider.GetCurrentTenantId(); var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); @@ -190,6 +210,7 @@ public sealed class DictionaryAppService( private async Task RequireGroupAsync(long groupId, CancellationToken cancellationToken) { + // 1. 读取分组,找不到抛异常 var group = await repository.FindGroupByIdAsync(groupId, cancellationToken); if (group == null) { @@ -201,6 +222,7 @@ public sealed class DictionaryAppService( private async Task RequireItemAsync(long itemId, CancellationToken cancellationToken) { + // 1. 读取字典项,找不到抛异常 var item = await repository.FindItemByIdAsync(itemId, cancellationToken); if (item == null) { @@ -269,12 +291,14 @@ public sealed class DictionaryAppService( private async Task> GetOrLoadCacheAsync(long tenantId, string code, CancellationToken cancellationToken) { + // 1. 先查缓存 var cached = await cache.GetAsync(tenantId, code, cancellationToken); if (cached != null) { return cached; } + // 2. 从仓储加载并写入缓存 var entities = await repository.GetItemsByCodesAsync(new[] { code }, tenantId, includeSystem: false, cancellationToken); var items = entities .Where(item => item.IsEnabled && (item.Group?.IsEnabled ?? true)) diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs index d1cd0a7..8961c72 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IAdminAuthService.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Shared.Abstractions.Results; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs index 4235181..5bf401e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IJwtTokenService.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Identity.Contracts; namespace TakeoutSaaS.Application.Identity.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs index f4a7c5c..66f91a9 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/ILoginRateLimiter.cs @@ -1,6 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; - namespace TakeoutSaaS.Application.Identity.Abstractions; /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs index c7a509e..8c5250a 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IMiniAuthService.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Identity.Contracts; namespace TakeoutSaaS.Application.Identity.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs index 29a1bf6..938b4cf 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IRefreshTokenStore.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Identity.Models; namespace TakeoutSaaS.Application.Identity.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs index 417c8b9..c53b72c 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Abstractions/IWeChatAuthService.cs @@ -1,6 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; - namespace TakeoutSaaS.Application.Identity.Abstractions; /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CopyRoleTemplateCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CopyRoleTemplateCommand.cs new file mode 100644 index 0000000..efaa1a3 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CopyRoleTemplateCommand.cs @@ -0,0 +1,30 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 从预置模板复制角色并绑定权限。 +/// +public sealed record CopyRoleTemplateCommand : IRequest +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; + + /// + /// 复制后角色名称(为空则使用模板名称)。 + /// + public string? RoleName { get; init; } + + /// + /// 复制后角色编码(为空则使用模板编码)。 + /// + public string? RoleCode { get; init; } + + /// + /// 角色描述(为空则沿用模板描述)。 + /// + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleTemplateCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleTemplateCommand.cs new file mode 100644 index 0000000..de2bd4c --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateRoleTemplateCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 创建角色模板命令。 +/// +public sealed record CreateRoleTemplateCommand : IRequest +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; + + /// + /// 模板名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 模板描述。 + /// + public string? Description { get; init; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; init; } = true; + + /// + /// 权限编码集合。 + /// + public IReadOnlyCollection PermissionCodes { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteRoleTemplateCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteRoleTemplateCommand.cs new file mode 100644 index 0000000..7f29db6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteRoleTemplateCommand.cs @@ -0,0 +1,14 @@ +using MediatR; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 删除角色模板命令。 +/// +public sealed record DeleteRoleTemplateCommand : IRequest +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs new file mode 100644 index 0000000..7eb39f9 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/InitializeRoleTemplatesCommand.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 批量为当前租户初始化角色模板。 +/// +public sealed record InitializeRoleTemplatesCommand : IRequest> +{ + /// + /// 需要初始化的模板编码列表(为空则全部)。 + /// + public IReadOnlyCollection? TemplateCodes { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleTemplateCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleTemplateCommand.cs new file mode 100644 index 0000000..ee6d5bf --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateRoleTemplateCommand.cs @@ -0,0 +1,35 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 更新角色模板命令。 +/// +public sealed record UpdateRoleTemplateCommand : IRequest +{ + /// + /// 模板编码(路径参数)。 + /// + public string TemplateCode { get; init; } = string.Empty; + + /// + /// 模板名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 模板描述。 + /// + public string? Description { get; init; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; init; } = true; + + /// + /// 权限编码集合。 + /// + public IReadOnlyCollection PermissionCodes { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionTemplateDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionTemplateDto.cs new file mode 100644 index 0000000..e3e1f34 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/PermissionTemplateDto.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 权限模板 DTO。 +/// +public sealed record PermissionTemplateDto +{ + /// + /// 权限编码。 + /// + public string Code { get; init; } = string.Empty; + + /// + /// 权限名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 权限描述。 + /// + public string? Description { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs new file mode 100644 index 0000000..d3d9415 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/RoleTemplateDto.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 角色模板 DTO。 +/// +public sealed record RoleTemplateDto +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; + + /// + /// 模板名称。 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 模板描述。 + /// + public string? Description { get; init; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; init; } + + /// + /// 包含的权限定义。 + /// + public IReadOnlyList Permissions { get; init; } = []; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs index f3393ad..3506491 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserPermissionDto.cs @@ -1,4 +1,3 @@ -using System; using System.Text.Json.Serialization; using TakeoutSaaS.Shared.Abstractions.Serialization; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/AssignUserRolesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/AssignUserRolesCommandHandler.cs index 8105f69..adaea12 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/AssignUserRolesCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/AssignUserRolesCommandHandler.cs @@ -15,9 +15,14 @@ public sealed class AssignUserRolesCommandHandler( { public async Task Handle(AssignUserRolesCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文 var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 覆盖式绑定角色 await userRoleRepository.ReplaceUserRolesAsync(tenantId, request.UserId, request.RoleIds, cancellationToken); await userRoleRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/BindRolePermissionsCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/BindRolePermissionsCommandHandler.cs index eee1e9e..d5a6d0e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/BindRolePermissionsCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/BindRolePermissionsCommandHandler.cs @@ -15,9 +15,14 @@ public sealed class BindRolePermissionsCommandHandler( { public async Task Handle(BindRolePermissionsCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文 var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 覆盖式绑定权限 await rolePermissionRepository.ReplaceRolePermissionsAsync(tenantId, request.RoleId, request.PermissionIds, cancellationToken); await rolePermissionRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs new file mode 100644 index 0000000..807371a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CopyRoleTemplateCommandHandler.cs @@ -0,0 +1,130 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 角色模板复制处理器。 +/// +public sealed class CopyRoleTemplateCommandHandler( + IRoleTemplateRepository roleTemplateRepository, + IRoleRepository roleRepository, + IPermissionRepository permissionRepository, + IRolePermissionRepository rolePermissionRepository, + ITenantProvider tenantProvider) + : IRequestHandler +{ + /// + public async Task Handle(CopyRoleTemplateCommand request, CancellationToken cancellationToken) + { + // 1. 查询模板与模板权限 + var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken) + ?? throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {request.TemplateCode} 不存在"); + + var templatePermissions = await roleTemplateRepository.GetPermissionsAsync(template.Id, cancellationToken); + var permissionCodes = templatePermissions + .Select(x => x.PermissionCode) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + // 2. 计算角色名称/编码与描述 + var tenantId = tenantProvider.GetCurrentTenantId(); + var roleCode = string.IsNullOrWhiteSpace(request.RoleCode) ? template.TemplateCode : request.RoleCode.Trim(); + var roleName = string.IsNullOrWhiteSpace(request.RoleName) ? template.Name : request.RoleName.Trim(); + var roleDescription = request.Description ?? template.Description; + + // 1. 准备或更新角色主体(幂等创建)。 + var role = await roleRepository.FindByCodeAsync(roleCode, tenantId, cancellationToken); + if (role is null) + { + role = new Role + { + TenantId = tenantId, + Name = roleName, + Code = roleCode, + Description = roleDescription + }; + await roleRepository.AddAsync(role, cancellationToken); + } + else + { + if (!string.IsNullOrWhiteSpace(request.RoleName)) + { + role.Name = roleName; + } + + if (request.Description is not null) + { + role.Description = roleDescription; + } + + await roleRepository.UpdateAsync(role, cancellationToken); + } + + // 3. 确保模板权限全部存在,不存在则按模板定义创建。 + var existingPermissions = await permissionRepository.GetByCodesAsync(tenantId, permissionCodes, cancellationToken); + var permissionMap = existingPermissions.ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase); + + foreach (var code in permissionCodes) + { + if (permissionMap.ContainsKey(code)) + { + continue; + } + + var permission = new Permission + { + TenantId = tenantId, + Name = code, + Code = code, + Description = code + }; + + await permissionRepository.AddAsync(permission, cancellationToken); + permissionMap[code] = permission; + } + + await roleRepository.SaveChangesAsync(cancellationToken); + + // 4. 绑定缺失的权限,保留租户自定义的已有授权。 + var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(tenantId, new[] { role.Id }, cancellationToken); + var existingPermissionIds = rolePermissions + .Select(x => x.PermissionId) + .ToHashSet(); + + var targetPermissionIds = permissionCodes + .Select(code => permissionMap[code].Id) + .ToHashSet(); + + var toAdd = targetPermissionIds.Except(existingPermissionIds).ToArray(); + if (toAdd.Length > 0) + { + var relations = toAdd.Select(permissionId => new RolePermission + { + TenantId = tenantId, + RoleId = role.Id, + PermissionId = permissionId + }); + + await rolePermissionRepository.AddRangeAsync(relations, cancellationToken); + } + + await rolePermissionRepository.SaveChangesAsync(cancellationToken); + + return new RoleDto + { + Id = role.Id, + TenantId = role.TenantId, + Name = role.Name, + Code = role.Code, + Description = role.Description + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreatePermissionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreatePermissionCommandHandler.cs index 275946e..1e5cfc0 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreatePermissionCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreatePermissionCommandHandler.cs @@ -17,7 +17,10 @@ public sealed class CreatePermissionCommandHandler( { public async Task Handle(CreatePermissionCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文 var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 构建权限实体 var permission = new Permission { TenantId = tenantId, @@ -26,9 +29,11 @@ public sealed class CreatePermissionCommandHandler( Description = request.Description }; + // 3. 持久化 await permissionRepository.AddAsync(permission, cancellationToken); await permissionRepository.SaveChangesAsync(cancellationToken); + // 4. 返回 DTO return new PermissionDto { Id = permission.Id, diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleCommandHandler.cs index 717393a..71fdcca 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleCommandHandler.cs @@ -17,7 +17,10 @@ public sealed class CreateRoleCommandHandler( { public async Task Handle(CreateRoleCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文 var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 构建角色实体 var role = new Role { TenantId = tenantId, @@ -26,9 +29,11 @@ public sealed class CreateRoleCommandHandler( Description = request.Description }; + // 3. 持久化 await roleRepository.AddAsync(role, cancellationToken); await roleRepository.SaveChangesAsync(cancellationToken); + // 4. 返回 DTO return new RoleDto { Id = role.Id, diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs new file mode 100644 index 0000000..2bec7ea --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateRoleTemplateCommandHandler.cs @@ -0,0 +1,55 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 创建角色模板处理器。 +/// +public sealed class CreateRoleTemplateCommandHandler(IRoleTemplateRepository roleTemplateRepository) + : IRequestHandler +{ + /// + public async Task Handle(CreateRoleTemplateCommand request, CancellationToken cancellationToken) + { + // 1. 校验必填 + if (string.IsNullOrWhiteSpace(request.TemplateCode) || string.IsNullOrWhiteSpace(request.Name)) + { + throw new BusinessException(ErrorCodes.BadRequest, "模板编码与名称不能为空"); + } + + // 2. 检查编码唯一 + var existing = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken); + if (existing != null) + { + throw new BusinessException(ErrorCodes.Conflict, $"模板编码 {request.TemplateCode} 已存在"); + } + + // 3. 构建模板实体 + var template = new RoleTemplate + { + TemplateCode = request.TemplateCode.Trim(), + Name = request.Name.Trim(), + Description = request.Description, + IsActive = request.IsActive + }; + + // 4. 清洗权限编码 + var permissions = request.PermissionCodes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + // 5. 持久化并返回 DTO + await roleTemplateRepository.AddAsync(template, permissions, cancellationToken); + await roleTemplateRepository.SaveChangesAsync(cancellationToken); + + return TemplateMapper.ToDto(template, permissions); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeletePermissionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeletePermissionCommandHandler.cs index 9dc2ce8..a494786 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeletePermissionCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeletePermissionCommandHandler.cs @@ -15,9 +15,14 @@ public sealed class DeletePermissionCommandHandler( { public async Task Handle(DeletePermissionCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文 var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 删除权限 await permissionRepository.DeleteAsync(request.PermissionId, tenantId, cancellationToken); await permissionRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleCommandHandler.cs index c45241a..66ab38c 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleCommandHandler.cs @@ -15,9 +15,14 @@ public sealed class DeleteRoleCommandHandler( { public async Task Handle(DeleteRoleCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文 var tenantId = tenantProvider.GetCurrentTenantId(); + + // 2. 删除角色 await roleRepository.DeleteAsync(request.RoleId, tenantId, cancellationToken); await roleRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回执行结果 return true; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleTemplateCommandHandler.cs new file mode 100644 index 0000000..b641553 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteRoleTemplateCommandHandler.cs @@ -0,0 +1,29 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Domain.Identity.Repositories; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 删除角色模板处理器。 +/// +public sealed class DeleteRoleTemplateCommandHandler(IRoleTemplateRepository roleTemplateRepository) + : IRequestHandler +{ + public async Task Handle(DeleteRoleTemplateCommand request, CancellationToken cancellationToken) + { + // 1. 查询模板 + var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken); + if (template == null) + { + return false; + } + + // 2. 删除并保存 + await roleTemplateRepository.DeleteAsync(template.Id, cancellationToken); + await roleTemplateRepository.SaveChangesAsync(cancellationToken); + + // 3. 返回执行结果 + return true; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs new file mode 100644 index 0000000..be16a6e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetRoleTemplateQueryHandler.cs @@ -0,0 +1,31 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Domain.Identity.Repositories; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 角色模板详情查询处理器。 +/// +public sealed class GetRoleTemplateQueryHandler(IRoleTemplateRepository roleTemplateRepository) + : IRequestHandler +{ + /// + public async Task Handle(GetRoleTemplateQuery request, CancellationToken cancellationToken) + { + // 1. 查询模板 + var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken); + if (template == null) + { + return null; + } + + // 2. 查询模板权限 + var permissions = await roleTemplateRepository.GetPermissionsAsync(template.Id, cancellationToken); + var codes = permissions.Select(x => x.PermissionCode).ToArray(); + + // 3. 返回 DTO + return TemplateMapper.ToDto(template, codes); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs index a5a2a42..915f386 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetUserPermissionsQueryHandler.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; @@ -20,26 +18,22 @@ public sealed class GetUserPermissionsQueryHandler( ITenantProvider tenantProvider) : IRequestHandler { - private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository; - private readonly IUserRoleRepository _userRoleRepository = userRoleRepository; - private readonly IRoleRepository _roleRepository = roleRepository; - private readonly IPermissionRepository _permissionRepository = permissionRepository; - private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task Handle(GetUserPermissionsQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var user = await _identityUserRepository.FindByIdAsync(request.UserId, cancellationToken); + // 1. 获取租户并查询用户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var user = await identityUserRepository.FindByIdAsync(request.UserId, cancellationToken); if (user == null || user.TenantId != tenantId) { return null; } + // 2. 解析角色与权限 var roleCodes = await ResolveUserRolesAsync(tenantId, user.Id, cancellationToken); var permissionCodes = await ResolveUserPermissionsAsync(tenantId, user.Id, cancellationToken); + // 3. 返回用户权限概览 return new UserPermissionDto { UserId = user.Id, @@ -55,34 +49,39 @@ public sealed class GetUserPermissionsQueryHandler( private async Task ResolveUserRolesAsync(long tenantId, long userId, CancellationToken cancellationToken) { - var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); + // 1. 查询用户角色关系 + var relations = await userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray(); if (roleIds.Length == 0) { return Array.Empty(); } - var roles = await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); + // 2. 查询角色编码 + var roles = await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); return roles.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } private async Task ResolveUserPermissionsAsync(long tenantId, long userId, CancellationToken cancellationToken) { - var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); + // 1. 查询用户角色关系 + var relations = await userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken); var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray(); if (roleIds.Length == 0) { return Array.Empty(); } - var rolePermissions = await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken); + // 2. 查询角色-权限关系 + var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken); var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray(); if (permissionIds.Length == 0) { return Array.Empty(); } - var permissions = await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken); + // 3. 查询权限编码 + var permissions = await permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken); return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs new file mode 100644 index 0000000..7ec7a0e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/InitializeRoleTemplatesCommandHandler.cs @@ -0,0 +1,62 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 租户角色模板批量初始化处理器。 +/// +public sealed class InitializeRoleTemplatesCommandHandler( + IRoleTemplateRepository roleTemplateRepository, + IMediator mediator) + : IRequestHandler> +{ + /// + public async Task> Handle(InitializeRoleTemplatesCommand request, CancellationToken cancellationToken) + { + // 1. 解析需要初始化的模板编码,默认取全部模板。 + var requestedCodes = request.TemplateCodes? + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var availableTemplates = await roleTemplateRepository.GetAllAsync(true, cancellationToken); + var availableCodes = availableTemplates.Select(t => t.TemplateCode).ToHashSet(StringComparer.OrdinalIgnoreCase); + + var targetCodes = requestedCodes?.Length > 0 + ? requestedCodes + : availableTemplates.Select(template => template.TemplateCode).ToArray(); + + if (targetCodes.Length == 0) + { + return Array.Empty(); + } + + foreach (var code in targetCodes) + { + if (!availableCodes.Contains(code)) + { + throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {code} 不存在或未启用"); + } + } + + // 2. 逐个复制模板,幂等写入角色与权限。 + var roles = new List(targetCodes.Length); + foreach (var templateCode in targetCodes) + { + var role = await mediator.Send(new CopyRoleTemplateCommand + { + TemplateCode = templateCode + }, cancellationToken); + + roles.Add(role); + } + + return roles; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs new file mode 100644 index 0000000..e99254b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ListRoleTemplatesQueryHandler.cs @@ -0,0 +1,36 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Domain.Identity.Repositories; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 角色模板列表查询处理器。 +/// +public sealed class ListRoleTemplatesQueryHandler(IRoleTemplateRepository roleTemplateRepository) + : IRequestHandler> +{ + /// + public async Task> Handle(ListRoleTemplatesQuery request, CancellationToken cancellationToken) + { + // 1. 查询模板与权限映射 + var templates = await roleTemplateRepository.GetAllAsync(request.IsActive, cancellationToken); + var permissionsMap = await roleTemplateRepository.GetPermissionsAsync(templates.Select(t => t.Id), cancellationToken); + + // 2. 排序并映射 DTO + var dtos = templates + .OrderBy(template => template.TemplateCode, StringComparer.OrdinalIgnoreCase) + .Select(template => + { + var codes = permissionsMap.TryGetValue(template.Id, out var perms) + ? (IReadOnlyCollection)perms.Select(p => p.PermissionCode).ToArray() + : Array.Empty(); + return TemplateMapper.ToDto(template, codes); + }) + .ToArray(); + + // 3. 返回结果 + return dtos; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs index 97bdd1b..df6245b 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchPermissionsQueryHandler.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; @@ -19,9 +17,11 @@ public sealed class SearchPermissionsQueryHandler( { public async Task> Handle(SearchPermissionsQuery request, CancellationToken cancellationToken) { + // 1. 获取租户上下文并查询权限 var tenantId = tenantProvider.GetCurrentTenantId(); var permissions = await permissionRepository.SearchAsync(tenantId, request.Keyword, cancellationToken); + // 2. 排序 var sorted = request.SortBy?.ToLowerInvariant() switch { "name" => request.SortDescending @@ -35,11 +35,13 @@ public sealed class SearchPermissionsQueryHandler( : permissions.OrderBy(x => x.CreatedAt) }; + // 3. 分页 var paged = sorted .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 4. 映射 DTO var items = paged.Select(permission => new PermissionDto { Id = permission.Id, @@ -49,6 +51,7 @@ public sealed class SearchPermissionsQueryHandler( Description = permission.Description }).ToList(); + // 5. 返回分页结果 return new PagedResult(items, request.Page, request.PageSize, permissions.Count); } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs index bd11a5d..7309de3 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchRolesQueryHandler.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; @@ -19,9 +17,11 @@ public sealed class SearchRolesQueryHandler( { public async Task> Handle(SearchRolesQuery request, CancellationToken cancellationToken) { + // 1. 获取租户上下文并查询角色 var tenantId = tenantProvider.GetCurrentTenantId(); var roles = await roleRepository.SearchAsync(tenantId, request.Keyword, cancellationToken); + // 2. 排序 var sorted = request.SortBy?.ToLowerInvariant() switch { "name" => request.SortDescending @@ -32,11 +32,13 @@ public sealed class SearchRolesQueryHandler( : roles.OrderBy(x => x.CreatedAt) }; + // 3. 分页 var paged = sorted .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 4. 映射 DTO var items = paged.Select(role => new RoleDto { Id = role.Id, @@ -46,6 +48,7 @@ public sealed class SearchRolesQueryHandler( Description = role.Description }).ToList(); + // 5. 返回分页结果 return new PagedResult(items, request.Page, request.PageSize, roles.Count); } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs index 07e3595..4184be8 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchUserPermissionsQueryHandler.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using MediatR; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Application.Identity.Queries; @@ -22,25 +19,21 @@ public sealed class SearchUserPermissionsQueryHandler( ITenantProvider tenantProvider) : IRequestHandler> { - private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository; - private readonly IUserRoleRepository _userRoleRepository = userRoleRepository; - private readonly IRoleRepository _roleRepository = roleRepository; - private readonly IPermissionRepository _permissionRepository = permissionRepository; - private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository; - private readonly ITenantProvider _tenantProvider = tenantProvider; - /// public async Task> Handle(SearchUserPermissionsQuery request, CancellationToken cancellationToken) { - var tenantId = _tenantProvider.GetCurrentTenantId(); - var users = await _identityUserRepository.SearchAsync(tenantId, request.Keyword, cancellationToken); + // 1. 获取租户并查询用户 + var tenantId = tenantProvider.GetCurrentTenantId(); + var users = await identityUserRepository.SearchAsync(tenantId, request.Keyword, cancellationToken); + // 2. 排序与分页 var sorted = SortUsers(users, request.SortBy, request.SortDescending); var paged = sorted .Skip((request.Page - 1) * request.PageSize) .Take(request.PageSize) .ToList(); + // 3. 解析角色与权限 var resolved = await ResolveRolesAndPermissionsAsync(tenantId, paged, cancellationToken); var items = paged.Select(user => new UserPermissionDto { @@ -81,23 +74,27 @@ public sealed class SearchUserPermissionsQueryHandler( IReadOnlyCollection users, CancellationToken cancellationToken) { + // 1. 查询用户角色关系 var userIds = users.Select(x => x.Id).ToArray(); - var userRoleRelations = await _userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken); + var userRoleRelations = await userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken); var roleIds = userRoleRelations.Select(x => x.RoleId).Distinct().ToArray(); + // 2. 查询角色信息 var roles = roleIds.Length == 0 ? Array.Empty() - : await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); + : await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); var roleCodeMap = roles.ToDictionary(r => r.Id, r => r.Code, comparer: EqualityComparer.Default); + // 3. 查询角色-权限关系 var rolePermissions = roleIds.Length == 0 ? Array.Empty() - : await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken); + : await rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken); var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray(); + // 4. 查询权限详情 var permissions = permissionIds.Length == 0 ? Array.Empty() - : await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken); + : await permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken); var permissionCodeMap = permissions.ToDictionary(p => p.Id, p => p.Code, comparer: EqualityComparer.Default); var rolePermissionsLookup = rolePermissions @@ -107,6 +104,7 @@ public sealed class SearchUserPermissionsQueryHandler( var result = new Dictionary(); foreach (var userId in userIds) { + // 5. 聚合用户角色与权限编码 var rolesForUser = userRoleRelations.Where(ur => ur.UserId == userId).Select(ur => ur.RoleId).Distinct().ToArray(); var roleCodes = rolesForUser .Select(rid => roleCodeMap.GetValueOrDefault(rid)) diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs new file mode 100644 index 0000000..aaf6d71 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/TemplateMapper.cs @@ -0,0 +1,35 @@ +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 模板 DTO 映射工具。 +/// +internal static class TemplateMapper +{ + /// + /// 将角色模板与权限编码集合映射为 DTO。 + /// + /// 角色模板实体。 + /// 权限编码集合。 + /// 模板 DTO。 + public static RoleTemplateDto ToDto(RoleTemplate template, IReadOnlyCollection permissionCodes) + { + return new RoleTemplateDto + { + TemplateCode = template.TemplateCode, + Name = template.Name, + Description = template.Description, + IsActive = template.IsActive, + Permissions = permissionCodes + .Select(code => new PermissionTemplateDto + { + Code = code, + Name = code, + Description = null + }) + .ToArray() + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdatePermissionCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdatePermissionCommandHandler.cs index b123164..cde1cdd 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdatePermissionCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdatePermissionCommandHandler.cs @@ -16,6 +16,7 @@ public sealed class UpdatePermissionCommandHandler( { public async Task Handle(UpdatePermissionCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文并查询权限 var tenantId = tenantProvider.GetCurrentTenantId(); var permission = await permissionRepository.FindByIdAsync(request.PermissionId, tenantId, cancellationToken); if (permission == null) @@ -23,12 +24,15 @@ public sealed class UpdatePermissionCommandHandler( return null; } + // 2. 更新字段 permission.Name = request.Name; permission.Description = request.Description; + // 3. 持久化 await permissionRepository.UpdateAsync(permission, cancellationToken); await permissionRepository.SaveChangesAsync(cancellationToken); + // 4. 返回 DTO return new PermissionDto { Id = permission.Id, diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleCommandHandler.cs index c9b6a2d..49898de 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleCommandHandler.cs @@ -16,6 +16,7 @@ public sealed class UpdateRoleCommandHandler( { public async Task Handle(UpdateRoleCommand request, CancellationToken cancellationToken) { + // 1. 获取租户上下文并查询角色 var tenantId = tenantProvider.GetCurrentTenantId(); var role = await roleRepository.FindByIdAsync(request.RoleId, tenantId, cancellationToken); if (role == null) @@ -23,12 +24,15 @@ public sealed class UpdateRoleCommandHandler( return null; } + // 2. 更新字段 role.Name = request.Name; role.Description = request.Description; + // 3. 持久化 await roleRepository.UpdateAsync(role, cancellationToken); await roleRepository.SaveChangesAsync(cancellationToken); + // 4. 返回 DTO return new RoleDto { Id = role.Id, diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleTemplateCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleTemplateCommandHandler.cs new file mode 100644 index 0000000..a98bf6e --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateRoleTemplateCommandHandler.cs @@ -0,0 +1,45 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Domain.Identity.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Identity.Handlers; + +/// +/// 更新角色模板处理器。 +/// +public sealed class UpdateRoleTemplateCommandHandler(IRoleTemplateRepository roleTemplateRepository) + : IRequestHandler +{ + /// + public async Task Handle(UpdateRoleTemplateCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.TemplateCode) || string.IsNullOrWhiteSpace(request.Name)) + { + throw new BusinessException(ErrorCodes.BadRequest, "模板编码与名称不能为空"); + } + + var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken); + if (template == null) + { + return null; + } + + template.Name = request.Name.Trim(); + template.Description = request.Description; + template.IsActive = request.IsActive; + + var permissions = request.PermissionCodes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + await roleTemplateRepository.UpdateAsync(template, permissions, cancellationToken); + await roleTemplateRepository.SaveChangesAsync(cancellationToken); + + return TemplateMapper.ToDto(template, permissions); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/GetRoleTemplateQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetRoleTemplateQuery.cs new file mode 100644 index 0000000..b282312 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetRoleTemplateQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 获取单个角色模板详情。 +/// +public sealed record GetRoleTemplateQuery : IRequest +{ + /// + /// 模板编码。 + /// + public string TemplateCode { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs new file mode 100644 index 0000000..1b76a6d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/ListRoleTemplatesQuery.cs @@ -0,0 +1,15 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 查询角色模板列表。 +/// +public sealed record ListRoleTemplatesQuery : IRequest> +{ + /// + /// 是否仅返回启用模板。 + /// + public bool? IsActive { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs index 4836eb8..72e8f2d 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.AspNetCore.Identity; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; @@ -80,7 +77,7 @@ public sealed class AdminAuthService( // 3. 撤销旧刷新令牌(防止重复使用) await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken); - + // 4. 生成新的令牌对 var profile = await BuildProfileAsync(user, cancellationToken); return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs index 5d83289..8234d7e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs @@ -1,5 +1,5 @@ -using System.Net; using Microsoft.AspNetCore.Http; +using System.Net; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Domain.Identity.Entities; @@ -54,7 +54,7 @@ public sealed class MiniAuthService( // 5. 登录成功后重置限流计数 await rateLimiter.ResetAsync(throttleKey, cancellationToken); - + // 6. 构建用户档案并生成令牌 var profile = BuildProfile(user); return await jwtTokenService.CreateTokensAsync(profile, isNew, cancellationToken); @@ -82,7 +82,7 @@ public sealed class MiniAuthService( // 3. 撤销旧刷新令牌(防止重复使用) await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken); - + // 4. 生成新的令牌对 var profile = BuildProfile(user); return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs b/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs index b471f0a..c17a6f7 100644 --- a/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs +++ b/src/Application/TakeoutSaaS.Application/Messaging/Abstractions/IEventPublisher.cs @@ -1,6 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; - namespace TakeoutSaaS.Application.Messaging.Abstractions; /// diff --git a/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs b/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs index 60b04d3..d87dc80 100644 --- a/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs +++ b/src/Application/TakeoutSaaS.Application/Messaging/Services/EventPublisher.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Messaging.Abstractions; using TakeoutSaaS.Module.Messaging.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs b/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs index 514b843..4eab7cc 100644 --- a/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs +++ b/src/Application/TakeoutSaaS.Application/Sms/Abstractions/IVerificationCodeService.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Sms.Contracts; namespace TakeoutSaaS.Application.Sms.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs index 402385f..5a1bca6 100644 --- a/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeRequest.cs @@ -10,8 +10,6 @@ namespace TakeoutSaaS.Application.Sms.Contracts; /// public sealed class SendVerificationCodeRequest(string phoneNumber, string scene, SmsProviderKind? provider = null) { - - /// /// 手机号(支持 +86 前缀或纯 11 位)。 /// diff --git a/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs index 5b6cf77..e9f84f1 100644 --- a/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs +++ b/src/Application/TakeoutSaaS.Application/Sms/Contracts/SendVerificationCodeResponse.cs @@ -1,5 +1,3 @@ -using System; - namespace TakeoutSaaS.Application.Sms.Contracts; /// diff --git a/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs b/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs index 034230c..9eb1262 100644 --- a/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Sms/Contracts/VerifyVerificationCodeRequest.cs @@ -8,8 +8,6 @@ namespace TakeoutSaaS.Application.Sms.Contracts; /// public sealed class VerifyVerificationCodeRequest(string phoneNumber, string scene, string code) { - - /// /// 手机号。 /// diff --git a/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs b/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs index 88806c9..1fbac0f 100644 --- a/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs +++ b/src/Application/TakeoutSaaS.Application/Sms/Services/VerificationCodeService.cs @@ -1,15 +1,14 @@ -using System.Security.Cryptography; -using System.Text; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; using TakeoutSaaS.Application.Sms.Abstractions; using TakeoutSaaS.Application.Sms.Contracts; using TakeoutSaaS.Application.Sms.Options; using TakeoutSaaS.Module.Sms.Abstractions; using TakeoutSaaS.Module.Sms.Models; using TakeoutSaaS.Module.Sms.Options; -using TakeoutSaaS.Module.Sms; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Tenancy; @@ -30,6 +29,7 @@ public sealed class VerificationCodeService( /// public async Task SendAsync(SendVerificationCodeRequest request, CancellationToken cancellationToken = default) { + // 1. 参数校验 if (string.IsNullOrWhiteSpace(request.PhoneNumber)) { throw new BusinessException(ErrorCodes.BadRequest, "手机号不能为空"); @@ -40,6 +40,7 @@ public sealed class VerificationCodeService( throw new BusinessException(ErrorCodes.BadRequest, "场景不能为空"); } + // 2. 解析模板与缓存键 var smsOptions = smsOptionsMonitor.CurrentValue; var codeOptions = codeOptionsMonitor.CurrentValue; var templateCode = ResolveTemplate(request.Scene, smsOptions); @@ -48,8 +49,10 @@ public sealed class VerificationCodeService( var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}"; var cooldownKey = $"{cacheKey}:cooldown"; + // 3. 检查冷却期 await EnsureCooldownAsync(cooldownKey, codeOptions.CooldownSeconds, cancellationToken).ConfigureAwait(false); + // 4. 生成验证码并发送短信 var code = GenerateCode(codeOptions.CodeLength); var variables = new Dictionary { { "code", code } }; var sender = senderResolver.Resolve(request.Provider); @@ -61,6 +64,7 @@ public sealed class VerificationCodeService( throw new BusinessException(ErrorCodes.InternalServerError, $"短信发送失败:{smsResult.Message}"); } + // 5. 写入验证码与冷却缓存 var expiresAt = DateTimeOffset.UtcNow.AddMinutes(codeOptions.ExpireMinutes); await cache.SetStringAsync(cacheKey, code, new DistributedCacheEntryOptions { @@ -83,11 +87,13 @@ public sealed class VerificationCodeService( /// public async Task VerifyAsync(VerifyVerificationCodeRequest request, CancellationToken cancellationToken = default) { + // 1. 基础校验 if (string.IsNullOrWhiteSpace(request.Code)) { return false; } + // 2. 读取验证码 var codeOptions = codeOptionsMonitor.CurrentValue; var phone = NormalizePhoneNumber(request.PhoneNumber); var tenantKey = tenantProvider.GetCurrentTenantId() == 0 ? "platform" : tenantProvider.GetCurrentTenantId().ToString(); @@ -99,6 +105,7 @@ public sealed class VerificationCodeService( return false; } + // 3. 比对成功后清除缓存 var success = string.Equals(cachedCode, request.Code, StringComparison.Ordinal); if (success) { diff --git a/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs b/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs index f164c5c..b92f66d 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Abstractions/IFileStorageService.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Application.Storage.Contracts; namespace TakeoutSaaS.Application.Storage.Abstractions; diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs index b757e36..48e05eb 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadRequest.cs @@ -10,8 +10,6 @@ namespace TakeoutSaaS.Application.Storage.Contracts; /// public sealed class DirectUploadRequest(UploadFileType fileType, string fileName, string contentType, long contentLength, string? requestOrigin) { - - /// /// 文件类型。 /// diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs index 4989657..de1ab0b 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/DirectUploadResponse.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace TakeoutSaaS.Application.Storage.Contracts; /// diff --git a/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs b/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs index ea19f91..43e12f2 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Contracts/UploadFileRequest.cs @@ -1,4 +1,3 @@ -using System.IO; using TakeoutSaaS.Application.Storage.Enums; namespace TakeoutSaaS.Application.Storage.Contracts; @@ -17,8 +16,6 @@ public sealed class UploadFileRequest( long contentLength, string? requestOrigin) { - - /// /// 文件分类。 /// diff --git a/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs b/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs index dd712ba..89efe3d 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Extensions/UploadFileTypeParser.cs @@ -1,4 +1,3 @@ -using System; using TakeoutSaaS.Application.Storage.Enums; namespace TakeoutSaaS.Application.Storage.Extensions; diff --git a/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs b/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs index f2105c5..a887bf9 100644 --- a/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs +++ b/src/Application/TakeoutSaaS.Application/Storage/Services/FileStorageService.cs @@ -1,13 +1,7 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; using TakeoutSaaS.Application.Storage.Abstractions; using TakeoutSaaS.Application.Storage.Contracts; using TakeoutSaaS.Application.Storage.Enums; @@ -35,11 +29,13 @@ public sealed class FileStorageService( /// public async Task UploadAsync(UploadFileRequest request, CancellationToken cancellationToken = default) { + // 1. 校验请求 if (request is null) { throw new BusinessException(ErrorCodes.BadRequest, "上传请求不能为空"); } + // 2. 读取安全配置并校验来源/大小/类型 var options = optionsMonitor.CurrentValue; var security = options.Security; ValidateOrigin(request.RequestOrigin, security); @@ -50,15 +46,18 @@ public sealed class FileStorageService( var contentType = NormalizeContentType(request.ContentType, extension); ResetStream(request.Content); + // 3. 生成对象键与元数据 var objectKey = BuildObjectKey(request.FileType, extension); var metadata = BuildMetadata(request.FileType); var expires = TimeSpan.FromMinutes(Math.Max(1, security.DefaultUrlExpirationMinutes)); var provider = providerResolver.Resolve(); + // 4. 上传到对象存储 var uploadResult = await provider.UploadAsync( new StorageUploadRequest(objectKey, request.Content, contentType, request.ContentLength, true, expires, metadata), cancellationToken).ConfigureAwait(false); + // 5. 追加防盗链签名并返回 var finalUrl = AppendAntiLeechToken(uploadResult.SignedUrl ?? uploadResult.Url, objectKey, expires, security); logger.LogInformation("文件上传成功:{ObjectKey} ({Size} bytes)", objectKey, request.ContentLength); @@ -73,11 +72,13 @@ public sealed class FileStorageService( /// public async Task CreateDirectUploadAsync(DirectUploadRequest request, CancellationToken cancellationToken = default) { + // 1. 校验请求 if (request is null) { throw new BusinessException(ErrorCodes.BadRequest, "直传请求不能为空"); } + // 2. 校验来源/大小/类型 var options = optionsMonitor.CurrentValue; var security = options.Security; ValidateOrigin(request.RequestOrigin, security); @@ -87,14 +88,17 @@ public sealed class FileStorageService( ValidateExtension(request.FileType, extension, security); var contentType = NormalizeContentType(request.ContentType, extension); + // 3. 构建直传参数 var objectKey = BuildObjectKey(request.FileType, extension); var provider = providerResolver.Resolve(); var expires = TimeSpan.FromMinutes(Math.Max(1, security.DefaultUrlExpirationMinutes)); + // 4. 向存储获取直传凭证 var directResult = await provider.CreateDirectUploadAsync( new StorageDirectUploadRequest(objectKey, contentType, request.ContentLength, expires), cancellationToken).ConfigureAwait(false); + // 5. 构造直传结果并追加防盗链 var finalDownloadUrl = directResult.SignedDownloadUrl != null ? AppendAntiLeechToken(directResult.SignedDownloadUrl, objectKey, expires, security) : null; diff --git a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj b/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj index acb8fc2..9b5901b 100644 Binary files a/src/Application/TakeoutSaaS.Application/TakeoutSaaS.Application.csproj 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 index cf8a78e..2f4022e 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs @@ -6,7 +6,7 @@ public static class DatabaseConstants { /// - /// 默认业务库(AppDatabase)。 + /// 默认业务库(AppDatabase). /// public const string AppDataSource = "AppDatabase"; diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs index 2d7ed97..3214155 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs @@ -5,15 +5,43 @@ namespace TakeoutSaaS.Shared.Abstractions.Constants; /// public static class ErrorCodes { + /// + /// 请求参数错误。 + /// public const int BadRequest = 400; + + /// + /// 未授权访问。 + /// public const int Unauthorized = 401; + + /// + /// 权限不足。 + /// public const int Forbidden = 403; + + /// + /// 资源未找到。 + /// public const int NotFound = 404; + + /// + /// 资源冲突。 + /// public const int Conflict = 409; + + /// + /// 校验失败。 + /// public const int ValidationFailed = 422; + + /// + /// 服务器内部错误。 + /// public const int InternalServerError = 500; - // 业务自定义区间(10000+) + /// + /// 业务自定义错误(10000+)。 + /// public const int BusinessError = 10001; } - diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs index 3783a93..0423468 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs @@ -29,6 +29,7 @@ public interface IDapperExecutor /// 连接角色(读/写)。 /// 命令委托,提供已打开的连接和取消标记。 /// 取消标记。 + /// 异步执行任务。 Task ExecuteAsync( string dataSourceName, DatabaseConnectionRole role, diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs index ad8aa43..ae8284b 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs @@ -1,5 +1,3 @@ -using System.Threading; - namespace TakeoutSaaS.Shared.Abstractions.Diagnostics; /// diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs index a87def6..f95bf0b 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace TakeoutSaaS.Shared.Abstractions.Exceptions; /// diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs index d3e8d6d..04f400c 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs @@ -8,24 +8,36 @@ public static class ApiResponse /// /// 仅返回成功消息(无数据)。 /// + /// 提示信息。 + /// 封装后的成功响应。 public static ApiResponse Success(string? message = "操作成功") => ApiResponse.Ok(message: message); /// /// 成功且携带数据。 /// + /// 业务数据。 + /// 提示信息。 + /// 封装后的成功响应。 public static ApiResponse Ok(object? data, string? message = "操作成功") => data is null ? ApiResponse.Ok(message: message) : ApiResponse.Ok(data, message); /// /// 错误返回。 /// + /// 错误码。 + /// 错误提示。 + /// 封装后的失败响应。 public static ApiResponse Failure(int code, string message) => ApiResponse.Error(code, message); /// /// 错误返回(附带详情)。 /// + /// 错误码。 + /// 错误提示。 + /// 错误详情。 + /// 封装后的失败响应。 public static ApiResponse Error(int code, string message, object? errors = null) => ApiResponse.Error(code, message, errors); } diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs index b49a215..f89a1b7 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs @@ -6,7 +6,7 @@ namespace TakeoutSaaS.Shared.Abstractions.Results; /// /// 统一的 API 返回结果包装。 /// -/// 数据载荷类型 +/// 数据载荷类型。 public sealed record ApiResponse { /// @@ -47,36 +47,53 @@ public sealed record ApiResponse /// /// 成功返回。 /// + /// 业务数据。 + /// 提示信息。 + /// 封装后的成功响应。 public static ApiResponse Ok(T data, string? message = "操作成功") => Create(true, 200, message, data); /// /// 无数据的成功返回。 /// + /// 提示信息。 + /// 封装后的成功响应。 public static ApiResponse Ok(string? message = "操作成功") => Create(true, 200, message, default); /// /// 兼容旧名称:成功结果。 /// + /// 业务数据。 + /// 提示信息。 + /// 封装后的成功响应。 public static ApiResponse SuccessResult(T data, string? message = "操作成功") => Ok(data, message); /// /// 错误返回。 /// + /// 错误码。 + /// 错误提示。 + /// 错误详情。 + /// 封装后的失败响应。 public static ApiResponse Error(int code, string message, object? errors = null) => Create(false, code, message, default, errors); /// /// 兼容旧名称:失败结果。 /// + /// 错误码。 + /// 错误提示。 + /// 封装后的失败响应。 public static ApiResponse Failure(int code, string message) => Error(code, message); /// /// 附加错误详情。 /// + /// 错误详情。 + /// 包含错误详情的新响应。 public ApiResponse WithErrors(object? errors) => this with { Errors = errors }; @@ -92,6 +109,10 @@ public sealed record ApiResponse Timestamp = DateTime.UtcNow }; + /// + /// 解析当前 TraceId。 + /// + /// 当前有效的 TraceId。 private static string ResolveTraceId() { if (!string.IsNullOrWhiteSpace(TraceContext.TraceId)) @@ -113,9 +134,19 @@ public sealed record ApiResponse } } +/// +/// 作为 TraceId 缺失时的本地雪花 ID 备用生成器。 +/// internal sealed class IdFallbackGenerator { + /// + /// 延迟初始化的单例实例承载。 + /// private static readonly Lazy Lazy = new(() => new IdFallbackGenerator()); + + /// + /// 获取备用雪花生成器单例。 + /// public static IdFallbackGenerator Instance => Lazy.Value; private readonly object _sync = new(); @@ -126,6 +157,10 @@ internal sealed class IdFallbackGenerator { } + /// + /// 生成雪花风格的本地备用 ID。 + /// + /// 本地生成的雪花 ID。 public long NextId() { lock (_sync) @@ -149,6 +184,11 @@ internal sealed class IdFallbackGenerator } } + /// + /// 等待到下一个毫秒以避免序列冲突。 + /// + /// 上一毫秒的时间戳。 + /// 下一个时间戳(毫秒)。 private static long WaitNextMillis(long lastTimestamp) { var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs index 69c5ba4..4bc4c7e 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace TakeoutSaaS.Shared.Abstractions.Results; /// diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs index 02c818a..76358db 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs @@ -6,7 +6,8 @@ namespace TakeoutSaaS.Shared.Abstractions.Tenancy; public interface ITenantProvider { /// - /// 获取当前租户 ID,未解析时返回 Guid.Empty。 + /// 获取当前租户 ID,未解析时返回 0。 /// + /// 当前请求绑定的租户 ID,未解析时为 0。 long GetCurrentTenantId(); } diff --git a/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs b/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs index 06d22fb..862edb9 100644 --- a/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs +++ b/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Security.Cryptography; -using System.Threading; using TakeoutSaaS.Shared.Abstractions.Ids; namespace TakeoutSaaS.Shared.Kernel.Ids; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs index dd5f1e3..7ac65ce 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Web.Filters; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs b/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs index 4c802e0..c6ad12f 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using TakeoutSaaS.Shared.Abstractions.Constants; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs index e1dadd9..eb7d0a9 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs @@ -1,9 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using System.Diagnostics; using TakeoutSaaS.Shared.Abstractions.Diagnostics; using TakeoutSaaS.Shared.Abstractions.Ids; @@ -49,10 +46,10 @@ public sealed class CorrelationIdMiddleware(RequestDelegate next, ILogger - { - ["TraceId"] = traceId, - ["SpanId"] = spanId - })) + { + ["TraceId"] = traceId, + ["SpanId"] = spanId + })) { try { diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs index 2babea2..c52d364 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs @@ -1,10 +1,8 @@ -using System; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Json.Serialization; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Results; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs index 7c5be5f..7162688 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs @@ -1,7 +1,6 @@ -using System.Diagnostics; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using System.Diagnostics; using TakeoutSaaS.Shared.Abstractions.Diagnostics; namespace TakeoutSaaS.Shared.Web.Middleware; @@ -11,7 +10,6 @@ namespace TakeoutSaaS.Shared.Web.Middleware; /// public sealed class RequestLoggingMiddleware(RequestDelegate next, ILogger logger) { - public async Task InvokeAsync(HttpContext context) { var stopwatch = Stopwatch.StartNew(); diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs index 05a90b2..630789b 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs @@ -1,4 +1,3 @@ -using System; using System.Security.Claims; namespace TakeoutSaaS.Shared.Web.Security; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs index e7f5209..bc43ea8 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs @@ -1,5 +1,5 @@ -using System.Security.Claims; using Microsoft.AspNetCore.Http; +using System.Security.Claims; using TakeoutSaaS.Shared.Abstractions.Security; namespace TakeoutSaaS.Shared.Web.Security; @@ -12,8 +12,6 @@ namespace TakeoutSaaS.Shared.Web.Security; /// public sealed class HttpContextCurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUserAccessor { - - /// public long UserId { diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs index e167f00..68590fc 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs @@ -1,6 +1,4 @@ -using System; -using Microsoft.AspNetCore.Mvc.ApiExplorer; -using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.OpenApi; diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs index 8b1fb25..51385fb 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs @@ -37,12 +37,6 @@ public static class SwaggerExtensions /// public static IApplicationBuilder UseSharedSwagger(this IApplicationBuilder app) { - var env = app.ApplicationServices.GetRequiredService(); - if (!env.IsDevelopment()) - { - return app; - } - var provider = app.ApplicationServices.GetRequiredService(); var settings = app.ApplicationServices.GetRequiredService(); diff --git a/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryOrder.cs b/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryOrder.cs index c547d6b..f408473 100644 --- a/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryOrder.cs +++ b/src/Domain/TakeoutSaaS.Domain/Deliveries/Entities/DeliveryOrder.cs @@ -8,6 +8,9 @@ namespace TakeoutSaaS.Domain.Deliveries.Entities; /// public sealed class DeliveryOrder : MultiTenantEntityBase { + /// + /// 获取或设置关联订单 ID。 + /// public long OrderId { get; set; } /// diff --git a/src/Domain/TakeoutSaaS.Domain/Deliveries/Repositories/IDeliveryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Deliveries/Repositories/IDeliveryRepository.cs index 27d5c95..f97f67e 100644 --- a/src/Domain/TakeoutSaaS.Domain/Deliveries/Repositories/IDeliveryRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Deliveries/Repositories/IDeliveryRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Deliveries.Entities; using TakeoutSaaS.Domain.Deliveries.Enums; @@ -14,45 +11,77 @@ public interface IDeliveryRepository /// /// 依据标识获取配送单。 /// + /// 配送单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 配送单实体或 null。 Task FindByIdAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default); /// /// 依据订单标识获取配送单。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 配送单实体或 null。 Task FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取配送事件轨迹。 /// + /// 配送单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 配送事件列表。 Task> GetEventsAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default); /// /// 新增配送单。 /// + /// 配送单实体。 + /// 取消标记。 + /// 异步任务。 Task AddDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default); /// /// 新增配送事件。 /// + /// 配送事件。 + /// 取消标记。 + /// 异步任务。 Task AddEventAsync(DeliveryEvent deliveryEvent, CancellationToken cancellationToken = default); /// /// 持久化变更。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); /// /// 按状态查询配送单。 /// + /// 租户 ID。 + /// 配送状态。 + /// 订单 ID。 + /// 取消标记。 + /// 配送单列表。 Task> SearchAsync(long tenantId, DeliveryStatus? status, long? orderId, CancellationToken cancellationToken = default); /// /// 更新配送单。 /// + /// 配送单实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default); /// /// 删除配送单及事件。 /// + /// 配送单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeleteDeliveryOrderAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs index 68694b0..bf48fca 100644 --- a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryGroup.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using TakeoutSaaS.Domain.Dictionary.Enums; using TakeoutSaaS.Shared.Abstractions.Entities; diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs index 9a9b427..d0d4294 100644 --- a/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Dictionary.Entities; using TakeoutSaaS.Domain.Dictionary.Enums; @@ -14,55 +11,89 @@ public interface IDictionaryRepository /// /// 依据 ID 获取分组。 /// + /// 分组 ID。 + /// 取消标记。 + /// 分组实体或 null。 Task FindGroupByIdAsync(long id, CancellationToken cancellationToken = default); /// /// 依据编码获取分组。 /// + /// 分组编码。 + /// 取消标记。 + /// 分组实体或 null。 Task FindGroupByCodeAsync(string code, CancellationToken cancellationToken = default); /// /// 搜索分组,可按作用域过滤。 /// + /// 作用域。 + /// 取消标记。 + /// 分组集合。 Task> SearchGroupsAsync(DictionaryScope? scope, CancellationToken cancellationToken = default); /// /// 新增分组。 /// + /// 分组实体。 + /// 取消标记。 + /// 异步任务。 Task AddGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default); /// /// 删除分组。 /// + /// 分组实体。 + /// 取消标记。 + /// 异步任务。 Task RemoveGroupAsync(DictionaryGroup group, CancellationToken cancellationToken = default); /// /// 依据 ID 获取字典项。 /// + /// 字典项 ID。 + /// 取消标记。 + /// 字典项或 null。 Task FindItemByIdAsync(long id, CancellationToken cancellationToken = default); /// /// 获取某分组下的所有字典项。 /// + /// 分组 ID。 + /// 取消标记。 + /// 字典项集合。 Task> GetItemsByGroupIdAsync(long groupId, CancellationToken cancellationToken = default); /// /// 按分组编码集合获取字典项(可包含系统参数)。 /// + /// 分组编码集合。 + /// 租户 ID。 + /// 是否包含系统分组。 + /// 取消标记。 + /// 字典项集合。 Task> GetItemsByCodesAsync(IEnumerable codes, long tenantId, bool includeSystem, CancellationToken cancellationToken = default); /// /// 新增字典项。 /// + /// 字典项实体。 + /// 取消标记。 + /// 异步任务。 Task AddItemAsync(DictionaryItem item, CancellationToken cancellationToken = default); /// /// 删除字典项。 /// + /// 字典项实体。 + /// 取消标记。 + /// 异步任务。 Task RemoveItemAsync(DictionaryItem item, CancellationToken cancellationToken = default); /// /// 持久化更改。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplate.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplate.cs new file mode 100644 index 0000000..2570321 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplate.cs @@ -0,0 +1,29 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 角色模板定义(平台级)。 +/// +public sealed class RoleTemplate : AuditableEntityBase +{ + /// + /// 模板编码(唯一)。 + /// + public string TemplateCode { get; set; } = string.Empty; + + /// + /// 模板名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 模板描述。 + /// + public string? Description { get; set; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; set; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplatePermission.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplatePermission.cs new file mode 100644 index 0000000..f3f9896 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/RoleTemplatePermission.cs @@ -0,0 +1,19 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Identity.Entities; + +/// +/// 角色模板-权限关系(平台级)。 +/// +public sealed class RoleTemplatePermission : AuditableEntityBase +{ + /// + /// 模板 ID。 + /// + public long RoleTemplateId { get; set; } + + /// + /// 权限编码。 + /// + public string PermissionCode { get; set; } = string.Empty; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs index 5b809cf..cc3a497 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Identity.Entities; namespace TakeoutSaaS.Domain.Identity.Repositories; @@ -14,11 +10,17 @@ public interface IIdentityUserRepository /// /// 根据账号获取后台用户。 /// + /// 账号。 + /// 取消标记。 + /// 后台用户或 null。 Task FindByAccountAsync(string account, CancellationToken cancellationToken = default); /// /// 根据 ID 获取后台用户。 /// + /// 用户 ID。 + /// 取消标记。 + /// 后台用户或 null。 Task FindByIdAsync(long userId, CancellationToken cancellationToken = default); /// @@ -27,10 +29,15 @@ public interface IIdentityUserRepository /// 租户 ID。 /// 可选关键字(账号/名称)。 /// 取消标记。 + /// 后台用户列表。 Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); /// /// 获取指定租户、用户集合对应的用户(只读)。 /// + /// 租户 ID。 + /// 用户 ID 集合。 + /// 取消标记。 + /// 后台用户列表。 Task> GetByIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs index 2bf97a0..d643394 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IPermissionRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Identity.Entities; namespace TakeoutSaaS.Domain.Identity.Repositories; @@ -10,12 +7,80 @@ namespace TakeoutSaaS.Domain.Identity.Repositories; /// public interface IPermissionRepository { + /// + /// 根据 ID 查询权限。 + /// + /// 权限 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 权限实体或 null。 Task FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 根据编码查询权限。 + /// + /// 权限编码。 + /// 租户 ID。 + /// 取消标记。 + /// 权限实体或 null。 Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 根据编码集合查询权限列表。 + /// + /// 租户 ID。 + /// 权限编码集合。 + /// 取消标记。 + /// 权限集合。 + Task> GetByCodesAsync(long tenantId, IEnumerable codes, CancellationToken cancellationToken = default); + + /// + /// 根据 ID 集合查询权限列表。 + /// + /// 租户 ID。 + /// 权限 ID 集合。 + /// 取消标记。 + /// 权限集合。 Task> GetByIdsAsync(long tenantId, IEnumerable permissionIds, CancellationToken cancellationToken = default); + + /// + /// 按关键字搜索权限。 + /// + /// 租户 ID。 + /// 关键字。 + /// 取消标记。 + /// 权限集合。 Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); + + /// + /// 新增权限。 + /// + /// 权限实体。 + /// 取消标记。 + /// 异步操作任务。 Task AddAsync(Permission permission, CancellationToken cancellationToken = default); + + /// + /// 更新权限。 + /// + /// 权限实体。 + /// 取消标记。 + /// 异步操作任务。 Task UpdateAsync(Permission permission, CancellationToken cancellationToken = default); + + /// + /// 删除权限。 + /// + /// 权限 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步操作任务。 Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 保存仓储变更。 + /// + /// 取消标记。 + /// 异步操作任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs index 6ace0ce..4502d1e 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRolePermissionRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Identity.Entities; namespace TakeoutSaaS.Domain.Identity.Repositories; @@ -10,7 +7,37 @@ namespace TakeoutSaaS.Domain.Identity.Repositories; /// public interface IRolePermissionRepository { + /// + /// 根据角色 ID 集合获取角色权限关系。 + /// + /// 租户 ID。 + /// 角色 ID 集合。 + /// 取消标记。 + /// 角色权限关系列表。 Task> GetByRoleIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default); + + /// + /// 批量新增角色权限关系。 + /// + /// 角色权限集合。 + /// 取消标记。 + /// 异步操作任务。 + Task AddRangeAsync(IEnumerable rolePermissions, CancellationToken cancellationToken = default); + + /// + /// 替换角色的权限集合。 + /// + /// 租户 ID。 + /// 角色 ID。 + /// 权限 ID 集合。 + /// 取消标记。 + /// 异步操作任务。 Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable permissionIds, CancellationToken cancellationToken = default); + + /// + /// 提交持久化变更。 + /// + /// 取消标记。 + /// 异步操作任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs index 822266e..726a513 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Identity.Entities; namespace TakeoutSaaS.Domain.Identity.Repositories; @@ -10,12 +7,71 @@ namespace TakeoutSaaS.Domain.Identity.Repositories; /// public interface IRoleRepository { + /// + /// 根据 ID 查询角色。 + /// + /// 角色 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 角色实体或 null。 Task FindByIdAsync(long roleId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 根据编码查询角色。 + /// + /// 角色编码。 + /// 租户 ID。 + /// 取消标记。 + /// 角色实体或 null。 Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 批量获取角色列表。 + /// + /// 租户 ID。 + /// 角色 ID 集合。 + /// 取消标记。 + /// 角色集合。 Task> GetByIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default); + + /// + /// 按关键字搜索角色。 + /// + /// 租户 ID。 + /// 关键字。 + /// 取消标记。 + /// 角色集合。 Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); + + /// + /// 新增角色。 + /// + /// 角色实体。 + /// 取消标记。 + /// 异步操作任务。 Task AddAsync(Role role, CancellationToken cancellationToken = default); + + /// + /// 更新角色。 + /// + /// 角色实体。 + /// 取消标记。 + /// 异步操作任务。 Task UpdateAsync(Role role, CancellationToken cancellationToken = default); + + /// + /// 删除角色。 + /// + /// 角色 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步操作任务。 Task DeleteAsync(long roleId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 保存仓储变更。 + /// + /// 取消标记。 + /// 异步操作任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs new file mode 100644 index 0000000..f175722 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IRoleTemplateRepository.cs @@ -0,0 +1,74 @@ +using TakeoutSaaS.Domain.Identity.Entities; + +namespace TakeoutSaaS.Domain.Identity.Repositories; + +/// +/// 角色模板仓储。 +/// +public interface IRoleTemplateRepository +{ + /// + /// 查询角色模板列表。 + /// + /// 启用状态过滤。 + /// 取消标记。 + /// 角色模板集合。 + Task> GetAllAsync(bool? isActive, CancellationToken cancellationToken = default); + + /// + /// 通过模板编码获取模板信息。 + /// + /// 模板编码。 + /// 取消标记。 + /// 模板实体或 null。 + Task FindByCodeAsync(string templateCode, CancellationToken cancellationToken = default); + + /// + /// 获取模板的权限列表。 + /// + /// 模板 ID。 + /// 取消标记。 + /// 权限集合。 + Task> GetPermissionsAsync(long roleTemplateId, CancellationToken cancellationToken = default); + + /// + /// 批量获取多个模板的权限映射。 + /// + /// 模板 ID 集合。 + /// 取消标记。 + /// 模板与权限列表的映射。 + Task>> GetPermissionsAsync(IEnumerable roleTemplateIds, CancellationToken cancellationToken = default); + + /// + /// 新增模板及其权限。 + /// + /// 模板实体。 + /// 权限编码集合。 + /// 取消标记。 + /// 异步操作任务。 + Task AddAsync(RoleTemplate template, IEnumerable permissionCodes, CancellationToken cancellationToken = default); + + /// + /// 更新模板及权限。 + /// + /// 模板实体。 + /// 权限编码集合。 + /// 取消标记。 + /// 异步操作任务。 + Task UpdateAsync(RoleTemplate template, IEnumerable permissionCodes, CancellationToken cancellationToken = default); + + /// + /// 删除模板。 + /// + /// 模板 ID。 + /// 取消标记。 + /// 异步操作任务。 + Task DeleteAsync(long roleTemplateId, CancellationToken cancellationToken = default); + + /// + /// 保存仓储变更。 + /// + /// 取消标记。 + /// 异步操作任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs index aa9b9c8..6759d08 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Identity.Entities; namespace TakeoutSaaS.Domain.Identity.Repositories; @@ -10,8 +7,38 @@ namespace TakeoutSaaS.Domain.Identity.Repositories; /// public interface IUserRoleRepository { + /// + /// 批量获取指定用户的角色关系。 + /// + /// 租户 ID。 + /// 用户 ID 集合。 + /// 取消标记。 + /// 用户角色关系集合。 Task> GetByUserIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default); + + /// + /// 获取单个用户的角色关系。 + /// + /// 租户 ID。 + /// 用户 ID。 + /// 取消标记。 + /// 指定用户的角色关系列表。 Task> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default); + + /// + /// 替换用户的角色列表。 + /// + /// 租户 ID。 + /// 用户 ID。 + /// 角色 ID 集合。 + /// 取消标记。 + /// 异步操作任务。 Task ReplaceUserRolesAsync(long tenantId, long userId, IEnumerable roleIds, CancellationToken cancellationToken = default); + + /// + /// 提交持久化变更。 + /// + /// 取消标记。 + /// 异步操作任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs index eee47f4..7f7b12f 100644 --- a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryBatch.cs @@ -1,3 +1,5 @@ +using System; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Shared.Abstractions.Entities; namespace TakeoutSaaS.Domain.Inventory.Entities; @@ -41,4 +43,10 @@ public sealed class InventoryBatch : MultiTenantEntityBase /// 剩余数量。 /// public int RemainingQuantity { get; set; } + + /// + /// 并发控制字段。 + /// + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs index 6aca234..32390c0 100644 --- a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryItem.cs @@ -1,4 +1,7 @@ +using System; +using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Shared.Abstractions.Entities; +using TakeoutSaaS.Domain.Inventory.Enums; namespace TakeoutSaaS.Domain.Inventory.Entities; @@ -46,4 +49,50 @@ public sealed class InventoryItem : MultiTenantEntityBase /// 过期日期。 /// public DateTime? ExpireDate { get; set; } + + /// + /// 是否预售商品。 + /// + public bool IsPresale { get; set; } + + /// + /// 预售开始时间(UTC)。 + /// + public DateTime? PresaleStartTime { get; set; } + + /// + /// 预售结束时间(UTC)。 + /// + public DateTime? PresaleEndTime { get; set; } + + /// + /// 预售名额(上限)。 + /// + public int? PresaleCapacity { get; set; } + + /// + /// 当前预售已锁定数量。 + /// + public int PresaleLocked { get; set; } + + /// + /// 单品限购(覆盖商品级 MaxQuantityPerOrder)。 + /// + public int? MaxQuantityPerOrder { get; set; } + + /// + /// 是否标记售罄。 + /// + public bool IsSoldOut { get; set; } + + /// + /// 批次扣减策略。 + /// + public InventoryBatchConsumeStrategy BatchConsumeStrategy { get; set; } = InventoryBatchConsumeStrategy.Fifo; + + /// + /// 并发控制字段。 + /// + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs new file mode 100644 index 0000000..89e6304 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Entities/InventoryLockRecord.cs @@ -0,0 +1,52 @@ +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Domain.Inventory.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Inventory.Entities; + +/// +/// 库存锁定记录。 +/// +public sealed class InventoryLockRecord : MultiTenantEntityBase +{ + /// + /// 门店 ID。 + /// + public long StoreId { get; set; } + + /// + /// SKU ID。 + /// + public long ProductSkuId { get; set; } + + /// + /// 锁定数量。 + /// + public int Quantity { get; set; } + + /// + /// 是否预售锁定。 + /// + public bool IsPresale { get; set; } + + /// + /// 幂等键。 + /// + public string IdempotencyKey { get; set; } = string.Empty; + + /// + /// 过期时间(UTC)。 + /// + public DateTime? ExpiresAt { get; set; } + + /// + /// 锁定状态。 + /// + public InventoryLockStatus Status { get; set; } = InventoryLockStatus.Locked; + + /// + /// 并发控制字段。 + /// + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryBatchConsumeStrategy.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryBatchConsumeStrategy.cs new file mode 100644 index 0000000..8ee0cef --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryBatchConsumeStrategy.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Inventory.Enums; + +/// +/// 批次扣减策略。 +/// +public enum InventoryBatchConsumeStrategy +{ + /// + /// 先进先出。 + /// + Fifo = 0, + + /// + /// 先到期先出。 + /// + Fefo = 1 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryLockStatus.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryLockStatus.cs new file mode 100644 index 0000000..a2be4a6 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Enums/InventoryLockStatus.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Inventory.Enums; + +/// +/// 库存锁定状态。 +/// +public enum InventoryLockStatus +{ + /// + /// 已锁定。 + /// + Locked = 0, + + /// + /// 已释放。 + /// + Released = 1, + + /// + /// 已扣减。 + /// + Deducted = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Inventory/Repositories/IInventoryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Inventory/Repositories/IInventoryRepository.cs new file mode 100644 index 0000000..bba4734 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Inventory/Repositories/IInventoryRepository.cs @@ -0,0 +1,95 @@ +using System; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TakeoutSaaS.Domain.Inventory.Entities; +using TakeoutSaaS.Domain.Inventory.Enums; + +namespace TakeoutSaaS.Domain.Inventory.Repositories; + +/// +/// 库存仓储契约。 +/// +public interface IInventoryRepository +{ + /// + /// 依据标识查询库存。 + /// + Task FindByIdAsync(long inventoryItemId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 按门店与 SKU 查询库存(只读)。 + /// + Task FindBySkuAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default); + + /// + /// 按门店与 SKU 查询库存(跟踪用于更新)。 + /// + Task GetForUpdateAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default); + + /// + /// 新增库存记录。 + /// + Task AddItemAsync(InventoryItem item, CancellationToken cancellationToken = default); + + /// + /// 更新库存记录。 + /// + Task UpdateItemAsync(InventoryItem item, CancellationToken cancellationToken = default); + + /// + /// 新增库存调整记录。 + /// + Task AddAdjustmentAsync(InventoryAdjustment adjustment, CancellationToken cancellationToken = default); + + /// + /// 新增锁定记录。 + /// + Task AddLockAsync(InventoryLockRecord lockRecord, CancellationToken cancellationToken = default); + + /// + /// 按幂等键查询锁记录。 + /// + Task FindLockByKeyAsync(long tenantId, string idempotencyKey, CancellationToken cancellationToken = default); + + /// + /// 更新锁状态。 + /// + Task MarkLockStatusAsync(InventoryLockRecord lockRecord, InventoryLockStatus status, CancellationToken cancellationToken = default); + + /// + /// 查询过期锁定。 + /// + Task> FindExpiredLocksAsync(long tenantId, DateTime utcNow, CancellationToken cancellationToken = default); + + /// + /// 查询批次列表。 + /// + Task> GetBatchesAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default); + + /// + /// 批次扣减读取(带排序策略)。 + /// + Task> GetBatchesForConsumeAsync(long tenantId, long storeId, long productSkuId, InventoryBatchConsumeStrategy strategy, CancellationToken cancellationToken = default); + + /// + /// 查询批次(跟踪用于更新)。 + /// + Task GetBatchForUpdateAsync(long tenantId, long storeId, long productSkuId, string batchNumber, CancellationToken cancellationToken = default); + + /// + /// 新增批次。 + /// + Task AddBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default); + + /// + /// 更新批次。 + /// + Task UpdateBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default); + + /// + /// 持久化变更。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantAuditLog.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantAuditLog.cs new file mode 100644 index 0000000..e41d3e6 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantAuditLog.cs @@ -0,0 +1,40 @@ +using TakeoutSaaS.Domain.Merchants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Merchants.Entities; + +/// +/// 商户入驻审核日志。 +/// +public sealed class MerchantAuditLog : MultiTenantEntityBase +{ + /// + /// 商户标识。 + /// + public long MerchantId { get; set; } + + /// + /// 动作类型。 + /// + public MerchantAuditAction Action { get; set; } + + /// + /// 标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 详情描述。 + /// + public string? Description { get; set; } + + /// + /// 操作人 ID。 + /// + public long? OperatorId { get; set; } + + /// + /// 操作人名称。 + /// + public string? OperatorName { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantCategory.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantCategory.cs new file mode 100644 index 0000000..d55dc26 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Entities/MerchantCategory.cs @@ -0,0 +1,24 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Merchants.Entities; + +/// +/// 商户可选类目。 +/// +public sealed class MerchantCategory : MultiTenantEntityBase +{ + /// + /// 类目名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 显示顺序,越小越靠前。 + /// + public int DisplayOrder { get; set; } + + /// + /// 是否可用。 + /// + public bool IsActive { get; set; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs new file mode 100644 index 0000000..330f6c4 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Enums/MerchantAuditAction.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Domain.Merchants.Enums; + +/// +/// 商户审核日志动作。 +/// +public enum MerchantAuditAction +{ + /// + /// 提交入驻申请或资料。 + /// + ApplicationSubmitted = 0, + + /// + /// 上传/更新证照。 + /// + DocumentUploaded = 1, + + /// + /// 证照审核。 + /// + DocumentReviewed = 2, + + /// + /// 合同创建或更新。 + /// + ContractUpdated = 3, + + /// + /// 合同状态变更(生效/终止)。 + /// + ContractStatusChanged = 4, + + /// + /// 商户审核结果。 + /// + MerchantReviewed = 5 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs new file mode 100644 index 0000000..cabdd80 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantCategoryRepository.cs @@ -0,0 +1,66 @@ +using TakeoutSaaS.Domain.Merchants.Entities; + +namespace TakeoutSaaS.Domain.Merchants.Repositories; + +/// +/// 商户类目仓储契约。 +/// +public interface IMerchantCategoryRepository +{ + /// + /// 列出当前租户的类目。 + /// + /// 租户 ID。 + /// 取消标记。 + /// 类目列表。 + Task> ListAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 是否存在同名类目。 + /// + /// 类目名称。 + /// 租户 ID。 + /// 取消标记。 + /// 存在返回 true。 + Task ExistsAsync(string name, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 查找类目。 + /// + /// 类目 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 类目实体或 null。 + Task FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增类目。 + /// + /// 类目实体。 + /// 取消标记。 + /// 异步任务。 + Task AddAsync(MerchantCategory category, CancellationToken cancellationToken = default); + + /// + /// 删除类目。 + /// + /// 类目实体。 + /// 取消标记。 + /// 异步任务。 + Task RemoveAsync(MerchantCategory category, CancellationToken cancellationToken = default); + + /// + /// 批量更新类目信息。 + /// + /// 类目集合。 + /// 取消标记。 + /// 异步任务。 + Task UpdateRangeAsync(IEnumerable categories, CancellationToken cancellationToken = default); + + /// + /// 持久化更改。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs index 9e8710a..1983bed 100644 --- a/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Merchants/Repositories/IMerchantRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Merchants.Entities; using TakeoutSaaS.Domain.Merchants.Enums; @@ -14,60 +11,169 @@ public interface IMerchantRepository /// /// 依据标识获取商户。 /// + /// 商户 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 商户实体或 null。 Task FindByIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); /// /// 按状态筛选商户列表。 /// + /// 租户 ID。 + /// 状态过滤。 + /// 取消标记。 + /// 商户集合。 Task> SearchAsync(long tenantId, MerchantStatus? status, CancellationToken cancellationToken = default); /// /// 获取指定商户的员工列表。 /// + /// 商户 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 员工集合。 Task> GetStaffAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 获取指定门店的员工列表。 + /// + Task> GetStaffByStoreAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 依据标识获取员工。 + /// + Task FindStaffByIdAsync(long staffId, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取指定商户的合同列表。 /// + /// 商户 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 合同集合。 Task> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 根据合同 ID 获取合同详情。 + /// + /// 商户 ID。 + /// 租户 ID。 + /// 合同 ID。 + /// 取消标记。 + /// 合同实体或 null。 + Task FindContractByIdAsync(long merchantId, long tenantId, long contractId, CancellationToken cancellationToken = default); + /// /// 获取指定商户的资质文件列表。 /// + /// 商户 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 资质文件列表。 Task> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 根据文件 ID 获取资质文件详情。 + /// + /// 商户 ID。 + /// 租户 ID。 + /// 文件 ID。 + /// 取消标记。 + /// 资质文件实体或 null。 + Task FindDocumentByIdAsync(long merchantId, long tenantId, long documentId, CancellationToken cancellationToken = default); + /// /// 新增商户主体。 /// + /// 商户实体。 + /// 取消标记。 + /// 异步任务。 Task AddMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default); /// /// 新增商户员工。 /// + /// 员工实体。 + /// 取消标记。 + /// 异步任务。 Task AddStaffAsync(MerchantStaff staff, CancellationToken cancellationToken = default); /// /// 新增商户合同。 /// + /// 合同实体。 + /// 取消标记。 + /// 异步任务。 Task AddContractAsync(MerchantContract contract, CancellationToken cancellationToken = default); + /// + /// 更新商户合同。 + /// + /// 合同实体。 + /// 取消标记。 + /// 异步操作任务。 + Task UpdateContractAsync(MerchantContract contract, CancellationToken cancellationToken = default); + /// /// 新增商户资质文件。 /// + /// 资质文件实体。 + /// 取消标记。 + /// 异步任务。 Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default); + /// + /// 更新商户资质文件。 + /// + /// 资质文件实体。 + /// 取消标记。 + /// 异步操作任务。 + Task UpdateDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default); + /// /// 持久化变更。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); /// /// 更新商户信息。 /// + /// 商户实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default); /// /// 删除商户。 /// + /// 商户 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeleteMerchantAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除员工。 + /// + Task DeleteStaffAsync(long staffId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 记录审核日志。 + /// + /// 审核日志实体。 + /// 取消标记。 + /// 异步任务。 + Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default); + + /// + /// 获取审核日志。 + /// + /// 商户 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 审核日志列表。 + Task> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs b/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs index d48ecf3..96a3bd6 100644 --- a/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Orders/Repositories/IOrderRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Orders.Entities; using TakeoutSaaS.Domain.Orders.Enums; using TakeoutSaaS.Domain.Payments.Enums; @@ -15,65 +12,111 @@ public interface IOrderRepository /// /// 依据标识获取订单。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 订单实体或 null。 Task FindByIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); /// /// 依据订单号获取订单。 /// + /// 订单号。 + /// 租户 ID。 + /// 取消标记。 + /// 订单实体或 null。 Task FindByOrderNoAsync(string orderNo, long tenantId, CancellationToken cancellationToken = default); /// /// 按状态筛选订单列表。 /// + /// 租户 ID。 + /// 订单状态。 + /// 支付状态。 + /// 取消标记。 + /// 订单集合。 Task> SearchAsync(long tenantId, OrderStatus? status, PaymentStatus? paymentStatus, CancellationToken cancellationToken = default); /// /// 获取订单明细行。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 订单明细集合。 Task> GetItemsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取订单状态流转记录。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 状态变更记录列表。 Task> GetStatusHistoryAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取订单退款申请。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 退款申请列表。 Task> GetRefundsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); /// /// 新增订单。 /// + /// 订单实体。 + /// 取消标记。 + /// 异步任务。 Task AddOrderAsync(Order order, CancellationToken cancellationToken = default); /// /// 新增订单明细。 /// + /// 明细集合。 + /// 取消标记。 + /// 异步任务。 Task AddItemsAsync(IEnumerable items, CancellationToken cancellationToken = default); /// /// 新增订单状态记录。 /// + /// 状态记录。 + /// 取消标记。 + /// 异步任务。 Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default); /// /// 新增退款申请。 /// + /// 退款申请实体。 + /// 取消标记。 + /// 异步任务。 Task AddRefundAsync(RefundRequest refund, CancellationToken cancellationToken = default); /// /// 持久化变更。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); /// /// 更新订单。 /// + /// 订单实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateOrderAsync(Order order, CancellationToken cancellationToken = default); /// /// 删除订单。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeleteOrderAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Payments/Repositories/IPaymentRepository.cs b/src/Domain/TakeoutSaaS.Domain/Payments/Repositories/IPaymentRepository.cs index f12c937..7983a5a 100644 --- a/src/Domain/TakeoutSaaS.Domain/Payments/Repositories/IPaymentRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Payments/Repositories/IPaymentRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Payments.Entities; using TakeoutSaaS.Domain.Payments.Enums; @@ -14,45 +11,76 @@ public interface IPaymentRepository /// /// 依据标识获取支付记录。 /// + /// 支付记录 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 支付记录或 null。 Task FindByIdAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default); /// /// 依据订单标识获取支付记录。 /// + /// 订单 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 支付记录或 null。 Task FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default); /// /// 获取支付对应的退款记录。 /// + /// 支付记录 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 退款记录列表。 Task> GetRefundsAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default); /// /// 新增支付记录。 /// + /// 支付实体。 + /// 取消标记。 + /// 异步任务。 Task AddPaymentAsync(PaymentRecord payment, CancellationToken cancellationToken = default); /// /// 新增退款记录。 /// + /// 退款实体。 + /// 取消标记。 + /// 异步任务。 Task AddRefundAsync(PaymentRefundRecord refund, CancellationToken cancellationToken = default); /// /// 持久化变更。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); /// /// 按状态筛选支付记录。 /// + /// 租户 ID。 + /// 支付状态。 + /// 取消标记。 + /// 支付记录列表。 Task> SearchAsync(long tenantId, PaymentStatus? status, CancellationToken cancellationToken = default); /// /// 更新支付记录。 /// + /// 支付实体。 + /// 取消标记。 + /// 异步任务。 Task UpdatePaymentAsync(PaymentRecord payment, CancellationToken cancellationToken = default); /// /// 删除支付记录及关联退款。 /// + /// 支付记录 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeletePaymentAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs index 6f01804..5b4563a 100644 --- a/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Products/Repositories/IProductRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Products.Entities; using TakeoutSaaS.Domain.Products.Enums; @@ -19,130 +16,253 @@ public interface IProductRepository /// /// 按分类与状态筛选商品列表。 /// - Task> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default); + Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null); /// /// 获取租户下的商品分类。 /// Task> GetCategoriesAsync(long tenantId, CancellationToken cancellationToken = default); + /// + /// 获取门店商品分类。 + /// + Task> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default); + /// /// 获取商品 SKU。 /// Task> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品 SKU。 + /// + Task> GetSkusByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品加料组与选项。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 加料组集合。 Task> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品加料组。 + /// + Task> GetAddonGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品加料选项。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 加料选项集合。 Task> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品加料选项。 + /// + Task> GetAddonOptionsByGroupIdsAsync(IReadOnlyCollection addonGroupIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品规格组与选项。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 规格组集合。 Task> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品规格组。 + /// + Task> GetAttributeGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品规格选项。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 规格选项集合。 Task> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品规格选项。 + /// + Task> GetAttributeOptionsByGroupIdsAsync(IReadOnlyCollection attributeGroupIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品媒资。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 媒资列表。 Task> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品媒资。 + /// + Task> GetMediaAssetsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取商品定价规则。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 定价规则集合。 Task> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 批量获取商品定价规则。 + /// + Task> GetPricingRulesByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default); + /// /// 新增分类。 /// + /// 分类实体。 + /// 取消标记。 + /// 异步任务。 Task AddCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default); /// /// 新增商品。 /// + /// 商品实体。 + /// 取消标记。 + /// 异步任务。 Task AddProductAsync(Product product, CancellationToken cancellationToken = default); /// /// 新增 SKU。 /// + /// SKU 集合。 + /// 取消标记。 + /// 异步任务。 Task AddSkusAsync(IEnumerable skus, CancellationToken cancellationToken = default); /// /// 新增加料组与选项。 /// + /// 加料组集合。 + /// 加料选项集合。 + /// 取消标记。 + /// 异步任务。 Task AddAddonGroupsAsync(IEnumerable groups, IEnumerable options, CancellationToken cancellationToken = default); /// /// 新增规格组与选项。 /// + /// 规格组集合。 + /// 规格选项集合。 + /// 取消标记。 + /// 异步任务。 Task AddAttributeGroupsAsync(IEnumerable groups, IEnumerable options, CancellationToken cancellationToken = default); /// /// 新增媒资。 /// + /// 媒资集合。 + /// 取消标记。 + /// 异步任务。 Task AddMediaAssetsAsync(IEnumerable assets, CancellationToken cancellationToken = default); /// /// 新增定价规则。 /// + /// 定价规则集合。 + /// 取消标记。 + /// 异步任务。 Task AddPricingRulesAsync(IEnumerable rules, CancellationToken cancellationToken = default); /// /// 持久化变更。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); /// /// 更新商品。 /// + /// 商品实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateProductAsync(Product product, CancellationToken cancellationToken = default); /// /// 删除商品。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeleteProductAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 更新分类。 /// + /// 分类实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default); /// /// 删除分类。 /// + /// 分类 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task DeleteCategoryAsync(long categoryId, long tenantId, CancellationToken cancellationToken = default); /// /// 删除商品下的 SKU。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task RemoveSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 删除商品下的加料组及选项。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task RemoveAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 删除商品下的规格组及选项。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task RemoveAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 删除商品媒资。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task RemoveMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default); /// /// 删除商品定价规则。 /// + /// 商品 ID。 + /// 租户 ID。 + /// 取消标记。 + /// 异步任务。 Task RemovePricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Queues/Entities/QueueTicket.cs b/src/Domain/TakeoutSaaS.Domain/Queues/Entities/QueueTicket.cs index e235909..2c07860 100644 --- a/src/Domain/TakeoutSaaS.Domain/Queues/Entities/QueueTicket.cs +++ b/src/Domain/TakeoutSaaS.Domain/Queues/Entities/QueueTicket.cs @@ -8,6 +8,9 @@ namespace TakeoutSaaS.Domain.Queues.Entities; /// public sealed class QueueTicket : MultiTenantEntityBase { + /// + /// 获取或设置所属门店 ID。 + /// public long StoreId { get; set; } /// diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSetting.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSetting.cs new file mode 100644 index 0000000..d47984c --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSetting.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店自提配置。 +/// +public sealed class StorePickupSetting : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 是否允许当天自提。 + /// + public bool AllowToday { get; set; } = true; + + /// + /// 可预约天数(含当天)。 + /// + public int AllowDaysAhead { get; set; } = 3; + + /// + /// 默认截单分钟(开始前多少分钟截止)。 + /// + public int DefaultCutoffMinutes { get; set; } = 30; + + /// + /// 单笔自提最大份数。 + /// + public int? MaxQuantityPerOrder { get; set; } + + /// + /// 并发控制字段。 + /// + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSlot.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSlot.cs new file mode 100644 index 0000000..8ab3f67 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Entities/StorePickupSlot.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Stores.Entities; + +/// +/// 门店自提档期。 +/// +public sealed class StorePickupSlot : MultiTenantEntityBase +{ + /// + /// 门店标识。 + /// + public long StoreId { get; set; } + + /// + /// 档期名称。 + /// + public string Name { get; set; } = string.Empty; + + /// + /// 当天开始时间(UTC)。 + /// + public TimeSpan StartTime { get; set; } + + /// + /// 当天结束时间(UTC)。 + /// + public TimeSpan EndTime { get; set; } + + /// + /// 截单分钟(开始前多少分钟截止)。 + /// + public int CutoffMinutes { get; set; } = 30; + + /// + /// 容量(份数)。 + /// + public int Capacity { get; set; } + + /// + /// 已占用数量。 + /// + public int ReservedCount { get; set; } + + /// + /// 适用星期(逗号分隔 1-7)。 + /// + public string Weekdays { get; set; } = "1,2,3,4,5,6,7"; + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + + /// + /// 并发控制字段。 + /// + [Timestamp] + public byte[] RowVersion { get; set; } = Array.Empty(); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs index bbbf7e0..fd941bc 100644 --- a/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Stores/Repositories/IStoreRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.Stores.Entities; using TakeoutSaaS.Domain.Stores.Enums; @@ -26,30 +23,100 @@ public interface IStoreRepository /// Task> GetBusinessHoursAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 依据标识获取营业时段。 + /// + Task FindBusinessHourByIdAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取门店配送区域配置。 /// Task> GetDeliveryZonesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 依据标识获取配送区域。 + /// + Task FindDeliveryZoneByIdAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取门店节假日配置。 /// Task> GetHolidaysAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 依据标识获取节假日配置。 + /// + Task FindHolidayByIdAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取门店桌台区域。 /// Task> GetTableAreasAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + /// + /// 依据标识获取桌台区域。 + /// + Task FindTableAreaByIdAsync(long areaId, long tenantId, CancellationToken cancellationToken = default); + /// /// 获取门店桌台列表。 /// Task> GetTablesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); /// - /// 获取门店员工排班。 + /// 依据标识获取桌台。 /// - Task> GetShiftsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + Task FindTableByIdAsync(long tableId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 依据桌码获取桌台。 + /// + Task FindTableByCodeAsync(string tableCode, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 获取门店自提配置。 + /// + Task GetPickupSettingAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增自提配置。 + /// + Task AddPickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default); + + /// + /// 更新自提配置。 + /// + Task UpdatePickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default); + + /// + /// 获取门店自提档期。 + /// + Task> GetPickupSlotsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 依据标识获取档期。 + /// + Task FindPickupSlotByIdAsync(long slotId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增加档期。 + /// + Task AddPickupSlotsAsync(IEnumerable slots, CancellationToken cancellationToken = default); + + /// + /// 更新档期。 + /// + Task UpdatePickupSlotAsync(StorePickupSlot slot, CancellationToken cancellationToken = default); + + /// + /// 获取门店员工排班(可选时间范围)。 + /// + Task> GetShiftsAsync(long storeId, long tenantId, DateTime? from = null, DateTime? to = null, CancellationToken cancellationToken = default); + + /// + /// 依据标识获取排班。 + /// + Task FindShiftByIdAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default); /// /// 新增门店。 @@ -61,36 +128,101 @@ public interface IStoreRepository /// Task AddBusinessHoursAsync(IEnumerable hours, CancellationToken cancellationToken = default); + /// + /// 更新营业时段。 + /// + Task UpdateBusinessHourAsync(StoreBusinessHour hour, CancellationToken cancellationToken = default); + /// /// 新增配送区域。 /// Task AddDeliveryZonesAsync(IEnumerable zones, CancellationToken cancellationToken = default); + /// + /// 更新配送区域。 + /// + Task UpdateDeliveryZoneAsync(StoreDeliveryZone zone, CancellationToken cancellationToken = default); + /// /// 新增节假日配置。 /// Task AddHolidaysAsync(IEnumerable holidays, CancellationToken cancellationToken = default); + /// + /// 更新节假日配置。 + /// + Task UpdateHolidayAsync(StoreHoliday holiday, CancellationToken cancellationToken = default); + /// /// 新增桌台区域。 /// Task AddTableAreasAsync(IEnumerable areas, CancellationToken cancellationToken = default); + /// + /// 更新桌台区域。 + /// + Task UpdateTableAreaAsync(StoreTableArea area, CancellationToken cancellationToken = default); + /// /// 新增桌台。 /// Task AddTablesAsync(IEnumerable tables, CancellationToken cancellationToken = default); + /// + /// 更新桌台。 + /// + Task UpdateTableAsync(StoreTable table, CancellationToken cancellationToken = default); + /// /// 新增排班。 /// Task AddShiftsAsync(IEnumerable shifts, CancellationToken cancellationToken = default); + /// + /// 更新排班。 + /// + Task UpdateShiftAsync(StoreEmployeeShift shift, CancellationToken cancellationToken = default); + /// /// 持久化变更。 /// Task SaveChangesAsync(CancellationToken cancellationToken = default); + /// + /// 删除营业时段。 + /// + Task DeleteBusinessHourAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除配送区域。 + /// + Task DeleteDeliveryZoneAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除节假日。 + /// + Task DeleteHolidayAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除桌台区域。 + /// + Task DeleteTableAreaAsync(long areaId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除桌台。 + /// + Task DeleteTableAsync(long tableId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除自提档期。 + /// + Task DeletePickupSlotAsync(long slotId, long tenantId, CancellationToken cancellationToken = default); + + /// + /// 删除排班。 + /// + Task DeleteShiftAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default); + /// /// 更新门店。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/SystemParameters/Repositories/ISystemParameterRepository.cs b/src/Domain/TakeoutSaaS.Domain/SystemParameters/Repositories/ISystemParameterRepository.cs index c222342..ced9e53 100644 --- a/src/Domain/TakeoutSaaS.Domain/SystemParameters/Repositories/ISystemParameterRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/SystemParameters/Repositories/ISystemParameterRepository.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Domain.SystemParameters.Entities; namespace TakeoutSaaS.Domain.SystemParameters.Repositories; @@ -13,35 +10,56 @@ public interface ISystemParameterRepository /// /// 根据标识获取系统参数。 /// + /// 参数 ID。 + /// 取消标记。 + /// 系统参数或 null。 Task FindByIdAsync(long id, CancellationToken cancellationToken = default); /// /// 根据键获取系统参数(当前租户)。 /// + /// 参数键。 + /// 取消标记。 + /// 系统参数或 null。 Task FindByKeyAsync(string key, CancellationToken cancellationToken = default); /// /// 查询系统参数列表。 /// + /// 关键字。 + /// 启用状态。 + /// 取消标记。 + /// 参数列表。 Task> SearchAsync(string? keyword, bool? isEnabled, CancellationToken cancellationToken = default); /// /// 新增系统参数。 /// + /// 参数实体。 + /// 取消标记。 + /// 异步任务。 Task AddAsync(SystemParameter parameter, CancellationToken cancellationToken = default); /// /// 删除系统参数。 /// + /// 参数实体。 + /// 取消标记。 + /// 异步任务。 Task RemoveAsync(SystemParameter parameter, CancellationToken cancellationToken = default); /// /// 更新系统参数。 /// + /// 参数实体。 + /// 取消标记。 + /// 异步任务。 Task UpdateAsync(SystemParameter parameter, CancellationToken cancellationToken = default); /// /// 持久化更改。 /// + /// 取消标记。 + /// 异步任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncement.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncement.cs new file mode 100644 index 0000000..451efdb --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncement.cs @@ -0,0 +1,45 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户公告。 +/// +public sealed class TenantAnnouncement : MultiTenantEntityBase +{ + /// + /// 公告标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 公告正文(可为 Markdown/HTML,前端自行渲染)。 + /// + public string Content { get; set; } = string.Empty; + + /// + /// 公告类型。 + /// + public TenantAnnouncementType AnnouncementType { get; set; } = TenantAnnouncementType.System; + + /// + /// 展示优先级,数值越大越靠前。 + /// + public int Priority { get; set; } = 0; + + /// + /// 生效时间(UTC)。 + /// + public DateTime EffectiveFrom { get; set; } = DateTime.UtcNow; + + /// + /// 失效时间(UTC),为空表示长期有效。 + /// + public DateTime? EffectiveTo { get; set; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; set; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncementRead.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncementRead.cs new file mode 100644 index 0000000..fa52716 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAnnouncementRead.cs @@ -0,0 +1,24 @@ +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户公告已读记录。 +/// +public sealed class TenantAnnouncementRead : MultiTenantEntityBase +{ + /// + /// 公告 ID。 + /// + public long AnnouncementId { get; set; } + + /// + /// 已读用户 ID(后台账号),为空表示租户级已读。 + /// + public long? UserId { get; set; } + + /// + /// 已读时间。 + /// + public DateTime ReadAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAuditLog.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAuditLog.cs new file mode 100644 index 0000000..79858ad --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantAuditLog.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户运营审核日志。 +/// +public sealed class TenantAuditLog : AuditableEntityBase +{ + /// + /// 关联的租户标识。 + /// + public long TenantId { get; set; } + + /// + /// 操作类型。 + /// + public TenantAuditAction Action { get; set; } + + /// + /// 日志标题。 + /// + public string Title { get; set; } = string.Empty; + + /// + /// 详细描述。 + /// + public string? Description { get; set; } + + /// + /// 操作人 ID。 + /// + public long? OperatorId { get; set; } + + /// + /// 操作人名称。 + /// + public string? OperatorName { get; set; } + + /// + /// 原状态。 + /// + public TenantStatus? PreviousStatus { get; set; } + + /// + /// 新状态。 + /// + public TenantStatus? CurrentStatus { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs new file mode 100644 index 0000000..9a47b77 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantSubscriptionHistory.cs @@ -0,0 +1,60 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户套餐订阅变更记录。 +/// +public sealed class TenantSubscriptionHistory : AuditableEntityBase +{ + /// + /// 租户标识。 + /// + public long TenantId { get; set; } + + /// + /// 对应的订阅 ID。 + /// + public long TenantSubscriptionId { get; set; } + + /// + /// 原套餐 ID。 + /// + public long FromPackageId { get; set; } + + /// + /// 新套餐 ID。 + /// + public long ToPackageId { get; set; } + + /// + /// 变更类型。 + /// + public SubscriptionChangeType ChangeType { get; set; } + + /// + /// 生效时间。 + /// + public DateTime EffectiveFrom { get; set; } + + /// + /// 到期时间。 + /// + public DateTime EffectiveTo { get; set; } + + /// + /// 相关费用。 + /// + public decimal? Amount { get; set; } + + /// + /// 币种。 + /// + public string? Currency { get; set; } + + /// + /// 备注。 + /// + public string? Notes { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs new file mode 100644 index 0000000..4a49fe9 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Entities/TenantVerificationProfile.cs @@ -0,0 +1,95 @@ +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Entities; + +/// +/// 租户实名认证资料。 +/// +public sealed class TenantVerificationProfile : AuditableEntityBase +{ + /// + /// 对应的租户标识。 + /// + public long TenantId { get; set; } + + /// + /// 实名状态。 + /// + public TenantVerificationStatus Status { get; set; } = TenantVerificationStatus.Draft; + + /// + /// 营业执照编号。 + /// + public string? BusinessLicenseNumber { get; set; } + + /// + /// 营业执照文件地址。 + /// + public string? BusinessLicenseUrl { get; set; } + + /// + /// 法人姓名。 + /// + public string? LegalPersonName { get; set; } + + /// + /// 法人身份证号。 + /// + public string? LegalPersonIdNumber { get; set; } + + /// + /// 法人身份证正面。 + /// + public string? LegalPersonIdFrontUrl { get; set; } + + /// + /// 法人身份证反面。 + /// + public string? LegalPersonIdBackUrl { get; set; } + + /// + /// 开户名。 + /// + public string? BankAccountName { get; set; } + + /// + /// 银行账号。 + /// + public string? BankAccountNumber { get; set; } + + /// + /// 银行名称。 + /// + public string? BankName { get; set; } + + /// + /// 附加资料(JSON)。 + /// + public string? AdditionalDataJson { get; set; } + + /// + /// 提交时间。 + /// + public DateTime? SubmittedAt { get; set; } + + /// + /// 审核时间。 + /// + public DateTime? ReviewedAt { get; set; } + + /// + /// 审核人 ID。 + /// + public long? ReviewedBy { get; set; } + + /// + /// 审核人姓名。 + /// + public string? ReviewedByName { get; set; } + + /// + /// 审核备注。 + /// + public string? ReviewRemarks { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs new file mode 100644 index 0000000..0eb9af5 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/SubscriptionChangeType.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 套餐订阅的操作类型。 +/// +public enum SubscriptionChangeType +{ + /// + /// 新订阅。 + /// + New = 0, + + /// + /// 续费。 + /// + Renew = 1, + + /// + /// 升级套餐。 + /// + Upgrade = 2, + + /// + /// 降级套餐。 + /// + Downgrade = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAnnouncementType.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAnnouncementType.cs new file mode 100644 index 0000000..d55a6d9 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAnnouncementType.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 租户公告类型。 +/// +public enum TenantAnnouncementType +{ + /// + /// 系统公告。 + /// + System = 0, + + /// + /// 账单/订阅相关提醒。 + /// + Billing = 1, + + /// + /// 运营通知。 + /// + Operation = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs new file mode 100644 index 0000000..0b31a62 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantAuditAction.cs @@ -0,0 +1,42 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 租户运营审核动作。 +/// +public enum TenantAuditAction +{ + /// + /// 注册信息提交。 + /// + RegistrationSubmitted = 1, + + /// + /// 实名资料提交或更新。 + /// + VerificationSubmitted = 2, + + /// + /// 实名审核通过。 + /// + VerificationApproved = 3, + + /// + /// 实名审核驳回。 + /// + VerificationRejected = 4, + + /// + /// 订阅创建或续费。 + /// + SubscriptionUpdated = 5, + + /// + /// 套餐升降配。 + /// + SubscriptionPlanChanged = 6, + + /// + /// 租户状态变更(启用/停用/到期等)。 + /// + StatusChanged = 7 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs new file mode 100644 index 0000000..88dbea7 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Enums/TenantVerificationStatus.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Domain.Tenants.Enums; + +/// +/// 租户实名认证状态。 +/// +public enum TenantVerificationStatus +{ + /// + /// 草稿,未提交审核。 + /// + Draft = 0, + + /// + /// 已提交审核,等待运营处理。 + /// + Pending = 1, + + /// + /// 审核通过。 + /// + Approved = 2, + + /// + /// 审核驳回。 + /// + Rejected = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs new file mode 100644 index 0000000..53af993 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementReadRepository.cs @@ -0,0 +1,53 @@ +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 公告已读仓储。 +/// +public interface ITenantAnnouncementReadRepository +{ + /// + /// 按公告查询已读记录。 + /// + /// 租户 ID。 + /// 公告 ID。 + /// 取消标记。 + /// 指定公告的已读列表。 + Task> GetByAnnouncementAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + + /// + /// 批量按公告查询已读记录,可选按用户过滤。 + /// + /// 租户 ID。 + /// 公告 ID 集合。 + /// 用户 ID,空则不按用户筛选。 + /// 取消标记。 + /// 匹配条件的已读列表。 + Task> GetByAnnouncementAsync(long tenantId, IEnumerable announcementIds, long? userId, CancellationToken cancellationToken = default); + + /// + /// 查询指定用户对某公告的已读记录。 + /// + /// 租户 ID。 + /// 公告 ID。 + /// 用户 ID。 + /// 取消标记。 + /// 已读记录,未读返回 null。 + Task FindAsync(long tenantId, long announcementId, long? userId, CancellationToken cancellationToken = default); + + /// + /// 新增已读记录。 + /// + /// 已读实体。 + /// 取消标记。 + /// 异步任务。 + Task AddAsync(TenantAnnouncementRead record, CancellationToken cancellationToken = default); + + /// + /// 保存变更。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs new file mode 100644 index 0000000..2314d10 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantAnnouncementRepository.cs @@ -0,0 +1,67 @@ +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户公告仓储。 +/// +public interface ITenantAnnouncementRepository +{ + /// + /// 查询公告列表,按类型、启用状态与生效时间筛选。 + /// + /// 租户 ID。 + /// 公告类型。 + /// 启用状态。 + /// 生效时间点,为空不限制。 + /// 取消标记。 + /// 公告集合。 + Task> SearchAsync( + long tenantId, + TenantAnnouncementType? type, + bool? isActive, + DateTime? effectiveAt, + CancellationToken cancellationToken = default); + + /// + /// 按 ID 获取公告。 + /// + /// 租户 ID。 + /// 公告 ID。 + /// 取消标记。 + /// 公告实体或 null。 + Task FindByIdAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + + /// + /// 新增公告。 + /// + /// 公告实体。 + /// 取消标记。 + /// 异步任务。 + Task AddAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default); + + /// + /// 更新公告。 + /// + /// 公告实体。 + /// 取消标记。 + /// 异步任务。 + Task UpdateAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default); + + /// + /// 删除公告。 + /// + /// 租户 ID。 + /// 公告 ID。 + /// 取消标记。 + /// 异步任务。 + Task DeleteAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default); + + /// + /// 保存变更。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs new file mode 100644 index 0000000..9edf1a4 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantBillingRepository.cs @@ -0,0 +1,67 @@ +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户账单仓储。 +/// +public interface ITenantBillingRepository +{ + /// + /// 查询账单列表,按状态与时间范围筛选。 + /// + /// 租户 ID。 + /// 账单状态。 + /// 开始时间(UTC)。 + /// 结束时间(UTC)。 + /// 取消标记。 + /// 账单集合。 + Task> SearchAsync( + long tenantId, + TenantBillingStatus? status, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default); + + /// + /// 按 ID 获取账单。 + /// + /// 租户 ID。 + /// 账单 ID。 + /// 取消标记。 + /// 账单实体或 null。 + Task FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default); + + /// + /// 按账单编号获取账单。 + /// + /// 租户 ID。 + /// 账单编号。 + /// 取消标记。 + /// 账单实体或 null。 + Task FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default); + + /// + /// 新增账单。 + /// + /// 账单实体。 + /// 取消标记。 + /// 异步任务。 + Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default); + + /// + /// 更新账单。 + /// + /// 账单实体。 + /// 取消标记。 + /// 异步任务。 + Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default); + + /// + /// 保存变更。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs new file mode 100644 index 0000000..c81f613 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantNotificationRepository.cs @@ -0,0 +1,60 @@ +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户通知仓储。 +/// +public interface ITenantNotificationRepository +{ + /// + /// 查询通知列表,按等级、未读状态与时间范围筛选。 + /// + /// 租户 ID。 + /// 通知等级。 + /// 仅返回未读。 + /// 开始时间(UTC)。 + /// 结束时间(UTC)。 + /// 取消标记。 + /// 通知集合。 + Task> SearchAsync( + long tenantId, + TenantNotificationSeverity? severity, + bool? unreadOnly, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default); + + /// + /// 按 ID 获取通知。 + /// + /// 租户 ID。 + /// 通知 ID。 + /// 取消标记。 + /// 通知实体或 null。 + Task FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default); + + /// + /// 新增通知。 + /// + /// 通知实体。 + /// 取消标记。 + /// 异步任务。 + Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default); + + /// + /// 更新通知。 + /// + /// 通知实体。 + /// 取消标记。 + /// 异步任务。 + Task UpdateAsync(TenantNotification notification, CancellationToken cancellationToken = default); + + /// + /// 保存变更。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs new file mode 100644 index 0000000..4fe61d5 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantPackageRepository.cs @@ -0,0 +1,57 @@ +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户套餐仓储。 +/// +public interface ITenantPackageRepository +{ + /// + /// 按套餐 ID 查询套餐。 + /// + /// 套餐 ID(雪花算法)。 + /// 取消标记。 + /// 匹配的套餐实体,未找到返回 null。 + Task FindByIdAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 按关键词与启用状态搜索套餐。 + /// + /// 名称或描述关键字,空则不按关键字过滤。 + /// 启用状态,空则不按状态过滤。 + /// 取消标记。 + /// 符合条件的套餐列表。 + Task> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default); + + /// + /// 新增套餐。 + /// + /// 套餐实体。 + /// 取消标记。 + /// 异步任务。 + Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default); + + /// + /// 更新套餐。 + /// + /// 套餐实体。 + /// 取消标记。 + /// 异步任务。 + Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default); + + /// + /// 删除套餐。 + /// + /// 套餐 ID(雪花算法)。 + /// 取消标记。 + /// 异步任务。 + Task DeleteAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 持久化。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs new file mode 100644 index 0000000..ff1b791 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantQuotaUsageRepository.cs @@ -0,0 +1,50 @@ +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户配额使用仓储。 +/// +public interface ITenantQuotaUsageRepository +{ + /// + /// 获取租户指定配额的使用情况。 + /// + /// 租户 ID(雪花算法)。 + /// 配额类型。 + /// 取消标记。 + /// 配额使用记录,未初始化则返回 null。 + Task FindAsync(long tenantId, TenantQuotaType quotaType, CancellationToken cancellationToken = default); + + /// + /// 按租户批量获取配额使用记录。 + /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 该租户的所有配额使用记录。 + Task> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增配额使用记录。 + /// + /// 配额使用实体。 + /// 取消标记。 + /// 异步任务。 + Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default); + + /// + /// 更新配额使用记录。 + /// + /// 配额使用实体。 + /// 取消标记。 + /// 异步任务。 + Task UpdateAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default); + + /// + /// 持久化。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs new file mode 100644 index 0000000..c396852 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs @@ -0,0 +1,142 @@ +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 租户聚合仓储。 +/// +public interface ITenantRepository +{ + /// + /// 依据 ID 获取租户。 + /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 租户实体,未找到返回 null。 + Task FindByIdAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 按状态与关键词查询租户列表。 + /// + /// 租户状态,为空不按状态过滤。 + /// 名称或编码关键字,为空不按关键字过滤。 + /// 取消标记。 + /// 符合条件的租户列表。 + Task> SearchAsync( + TenantStatus? status, + string? keyword, + CancellationToken cancellationToken = default); + + /// + /// 新增租户。 + /// + /// 租户实体。 + /// 取消标记。 + /// 异步任务。 + Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default); + + /// + /// 更新租户。 + /// + /// 租户实体。 + /// 取消标记。 + /// 异步任务。 + Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default); + + /// + /// 判断编码是否存在。 + /// + /// 租户编码。 + /// 取消标记。 + /// 存在返回 true,否则 false。 + Task ExistsByCodeAsync(string code, CancellationToken cancellationToken = default); + + /// + /// 获取实名资料。 + /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 实名资料实体,未提交返回 null。 + Task GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增或更新实名资料。 + /// + /// 实名资料实体。 + /// 取消标记。 + /// 异步任务。 + Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default); + + /// + /// 获取当前订阅。 + /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 当前有效订阅,若无则 null。 + Task GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 依据订阅 ID 查询。 + /// + /// 租户 ID(雪花算法)。 + /// 订阅 ID(雪花算法)。 + /// 取消标记。 + /// 订阅实体,未找到返回 null。 + Task FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default); + + /// + /// 新增订阅。 + /// + /// 订阅实体。 + /// 取消标记。 + /// 异步任务。 + Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default); + + /// + /// 更新订阅。 + /// + /// 订阅实体。 + /// 取消标记。 + /// 异步任务。 + Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default); + + /// + /// 记录订阅历史。 + /// + /// 订阅历史实体。 + /// 取消标记。 + /// 异步任务。 + Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default); + + /// + /// 获取订阅历史。 + /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 订阅历史列表。 + Task> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 新增审核日志。 + /// + /// 审核日志实体。 + /// 取消标记。 + /// 异步任务。 + Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default); + + /// + /// 查询审核日志。 + /// + /// 租户 ID(雪花算法)。 + /// 取消标记。 + /// 审核日志列表。 + Task> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default); + + /// + /// 持久化。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs index 2e76835..6011bd9 100644 --- a/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs +++ b/src/Gateway/TakeoutSaaS.ApiGateway/Program.cs @@ -1,21 +1,18 @@ -using System.Diagnostics; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.RateLimiting; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Serilog; -using TakeoutSaaS.ApiGateway.Configuration; +using System.Diagnostics; using System.Threading.RateLimiting; +using TakeoutSaaS.ApiGateway.Configuration; const string CorsPolicyName = "GatewayCors"; +// 1. 创建构建器并配置 Serilog var builder = WebApplication.CreateBuilder(args); - builder.Host.UseSerilog((context, services, loggerConfiguration) => { loggerConfiguration @@ -24,9 +21,11 @@ builder.Host.UseSerilog((context, services, loggerConfiguration) => .Enrich.FromLogContext(); }); +// 2. 配置 YARP 反向代理 builder.Services.AddReverseProxy() .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")); +// 3. 转发头部配置 builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; @@ -34,6 +33,7 @@ builder.Services.Configure(options => options.KnownProxies.Clear(); }); +// 4. 配置 CORS builder.Services.AddCors(options => { options.AddPolicy(CorsPolicyName, policy => @@ -44,6 +44,7 @@ builder.Services.AddCors(options => }); }); +// 5. 配置网关限流 builder.Services.Configure(builder.Configuration.GetSection("Gateway:RateLimiting")); var rateLimitOptions = builder.Configuration.GetSection("Gateway:RateLimiting").Get() ?? new(); @@ -66,6 +67,7 @@ if (rateLimitOptions.Enabled) }); } +// 6. 配置 OpenTelemetry var otelOptions = builder.Configuration.GetSection("OpenTelemetry").Get() ?? new(); if (otelOptions.Enabled) { @@ -117,10 +119,13 @@ if (otelOptions.Enabled) }); } +// 7. 构建应用 var app = builder.Build(); +// 8. 转发头中间件 app.UseForwardedHeaders(); +// 9. 全局异常处理中间件 app.UseExceptionHandler(errorApp => { // 1. 捕获所有未处理异常并返回统一结构。 @@ -145,6 +150,7 @@ app.UseExceptionHandler(errorApp => }); }); +// 10. 请求日志 app.UseSerilogRequestLogging(options => { options.MessageTemplate = "网关请求 {RequestMethod} {RequestPath} => {StatusCode} 用时 {Elapsed:0.000} 秒"; @@ -156,6 +162,7 @@ app.UseSerilogRequestLogging(options => }; }); +// 11. CORS 与限流 app.UseCors(CorsPolicyName); if (rateLimitOptions.Enabled) @@ -163,6 +170,7 @@ if (rateLimitOptions.Enabled) app.UseRateLimiter(); } +// 12. 透传请求头并保证 Trace app.Use(async (context, next) => { // 1. 确保请求拥有可追踪的 ID。 @@ -187,6 +195,7 @@ app.Use(async (context, next) => await next(context); }); +// 13. 映射反向代理与健康接口 app.MapReverseProxy(); app.MapGet("/", () => Results.Json(new diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json index 6c70a10..0508d91 100644 --- a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json +++ b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.Development.json @@ -3,23 +3,20 @@ "Enabled": false }, "ReverseProxy": { - "Routes": [ - { - "RouteId": "admin-route", + "Routes": { + "admin-route": { "ClusterId": "admin", "Match": { "Path": "/api/admin/{**catch-all}" } }, - { - "RouteId": "mini-route", + "mini-route": { "ClusterId": "mini", "Match": { "Path": "/api/mini/{**catch-all}" } }, - { - "RouteId": "user-route", + "user-route": { "ClusterId": "user", "Match": { "Path": "/api/user/{**catch-all}" } } - ], + }, "Clusters": { "admin": { "Destinations": { diff --git a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json index a2f3c3c..4bd7d04 100644 --- a/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json +++ b/src/Gateway/TakeoutSaaS.ApiGateway/appsettings.json @@ -36,23 +36,20 @@ "OtlpEndpoint": "http://localhost:4317" }, "ReverseProxy": { - "Routes": [ - { - "RouteId": "admin-route", + "Routes": { + "admin-route": { "ClusterId": "admin", "Match": { "Path": "/api/admin/{**catch-all}" } }, - { - "RouteId": "mini-route", + "mini-route": { "ClusterId": "mini", "Match": { "Path": "/api/mini/{**catch-all}" } }, - { - "RouteId": "user-route", + "user-route": { "ClusterId": "user", "Match": { "Path": "/api/user/{**catch-all}" } } - ], + }, "Clusters": { "admin": { "Destinations": { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index 02df728..7333909 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -1,11 +1,13 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Domain.Deliveries.Repositories; +using TakeoutSaaS.Domain.Inventory.Repositories; using TakeoutSaaS.Domain.Merchants.Repositories; using TakeoutSaaS.Domain.Orders.Repositories; using TakeoutSaaS.Domain.Payments.Repositories; using TakeoutSaaS.Domain.Products.Repositories; using TakeoutSaaS.Domain.Stores.Repositories; +using TakeoutSaaS.Domain.Tenants.Repositories; using TakeoutSaaS.Infrastructure.App.Options; using TakeoutSaaS.Infrastructure.App.Persistence; using TakeoutSaaS.Infrastructure.App.Repositories; @@ -31,11 +33,20 @@ public static class AppServiceCollectionExtensions services.AddPostgresDbContext(DatabaseConstants.AppDataSource); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddOptions() .Bind(configuration.GetSection(AppSeedOptions.SectionName)) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/AppSeedOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/AppSeedOptions.cs index b3ec66d..4099e12 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/AppSeedOptions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/AppSeedOptions.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace TakeoutSaaS.Infrastructure.App.Options; /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedGroupOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedGroupOptions.cs index 2cec142..c59ebb4 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedGroupOptions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Options/DictionarySeedGroupOptions.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using TakeoutSaaS.Domain.Dictionary.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs index 4072f0c..bd3fca8 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/AppDataSeeder.cs @@ -1,11 +1,9 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using TakeoutSaaS.Domain.Dictionary.Entities; -using TakeoutSaaS.Domain.Dictionary.Enums; using TakeoutSaaS.Domain.SystemParameters.Entities; using TakeoutSaaS.Domain.Tenants.Entities; using TakeoutSaaS.Domain.Tenants.Enums; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs index 14d4ff7..0d38a33 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Persistence/TakeoutAppDbContext.cs @@ -39,14 +39,21 @@ public sealed class TakeoutAppDbContext( public DbSet Tenants => Set(); public DbSet TenantPackages => Set(); public DbSet TenantSubscriptions => Set(); + public DbSet TenantSubscriptionHistories => Set(); public DbSet TenantQuotaUsages => Set(); public DbSet TenantBillingStatements => Set(); public DbSet TenantNotifications => Set(); + public DbSet TenantAnnouncements => Set(); + public DbSet TenantAnnouncementReads => Set(); + public DbSet TenantVerificationProfiles => Set(); + public DbSet TenantAuditLogs => Set(); public DbSet Merchants => Set(); public DbSet MerchantDocuments => Set(); public DbSet MerchantContracts => Set(); public DbSet MerchantStaff => Set(); + public DbSet MerchantAuditLogs => Set(); + public DbSet MerchantCategories => Set(); public DbSet Stores => Set(); public DbSet StoreBusinessHours => Set(); @@ -55,6 +62,8 @@ public sealed class TakeoutAppDbContext( public DbSet StoreTableAreas => Set(); public DbSet StoreTables => Set(); public DbSet StoreEmployeeShifts => Set(); + public DbSet StorePickupSettings => Set(); + public DbSet StorePickupSlots => Set(); public DbSet ProductCategories => Set(); public DbSet Products => Set(); @@ -69,6 +78,7 @@ public sealed class TakeoutAppDbContext( public DbSet InventoryItems => Set(); public DbSet InventoryAdjustments => Set(); public DbSet InventoryBatches => Set(); + public DbSet InventoryLockRecords => Set(); public DbSet ShoppingCarts => Set(); public DbSet CartItems => Set(); @@ -132,18 +142,27 @@ public sealed class TakeoutAppDbContext( ConfigureStore(modelBuilder.Entity()); ConfigureTenantPackage(modelBuilder.Entity()); ConfigureTenantSubscription(modelBuilder.Entity()); + ConfigureTenantSubscriptionHistory(modelBuilder.Entity()); ConfigureTenantQuotaUsage(modelBuilder.Entity()); ConfigureTenantBilling(modelBuilder.Entity()); ConfigureTenantNotification(modelBuilder.Entity()); + ConfigureTenantAnnouncement(modelBuilder.Entity()); + ConfigureTenantAnnouncementRead(modelBuilder.Entity()); + ConfigureTenantVerificationProfile(modelBuilder.Entity()); + ConfigureTenantAuditLog(modelBuilder.Entity()); ConfigureMerchantDocument(modelBuilder.Entity()); ConfigureMerchantContract(modelBuilder.Entity()); ConfigureMerchantStaff(modelBuilder.Entity()); + ConfigureMerchantAuditLog(modelBuilder.Entity()); + ConfigureMerchantCategory(modelBuilder.Entity()); ConfigureStoreBusinessHour(modelBuilder.Entity()); ConfigureStoreHoliday(modelBuilder.Entity()); ConfigureStoreDeliveryZone(modelBuilder.Entity()); ConfigureStoreTableArea(modelBuilder.Entity()); ConfigureStoreTable(modelBuilder.Entity()); ConfigureStoreEmployeeShift(modelBuilder.Entity()); + ConfigureStorePickupSetting(modelBuilder.Entity()); + ConfigureStorePickupSlot(modelBuilder.Entity()); ConfigureProductCategory(modelBuilder.Entity()); ConfigureProduct(modelBuilder.Entity()); ConfigureProductAttributeGroup(modelBuilder.Entity()); @@ -156,6 +175,7 @@ public sealed class TakeoutAppDbContext( ConfigureInventoryItem(modelBuilder.Entity()); ConfigureInventoryAdjustment(modelBuilder.Entity()); ConfigureInventoryBatch(modelBuilder.Entity()); + ConfigureInventoryLockRecord(modelBuilder.Entity()); ConfigureShoppingCart(modelBuilder.Entity()); ConfigureCartItem(modelBuilder.Entity()); ConfigureCartItemAddon(modelBuilder.Entity()); @@ -216,6 +236,47 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => x.Code).IsUnique(); } + private static void ConfigureTenantVerificationProfile(EntityTypeBuilder builder) + { + builder.ToTable("tenant_verification_profiles"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.BusinessLicenseNumber).HasMaxLength(64); + builder.Property(x => x.BusinessLicenseUrl).HasMaxLength(512); + builder.Property(x => x.LegalPersonName).HasMaxLength(64); + builder.Property(x => x.LegalPersonIdNumber).HasMaxLength(32); + builder.Property(x => x.LegalPersonIdFrontUrl).HasMaxLength(512); + builder.Property(x => x.LegalPersonIdBackUrl).HasMaxLength(512); + builder.Property(x => x.BankAccountName).HasMaxLength(128); + builder.Property(x => x.BankAccountNumber).HasMaxLength(64); + builder.Property(x => x.BankName).HasMaxLength(128); + builder.Property(x => x.ReviewRemarks).HasMaxLength(512); + builder.Property(x => x.ReviewedByName).HasMaxLength(64); + builder.HasIndex(x => x.TenantId).IsUnique(); + } + + private static void ConfigureTenantAuditLog(EntityTypeBuilder builder) + { + builder.ToTable("tenant_audit_logs"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.Title).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(1024); + builder.Property(x => x.OperatorName).HasMaxLength(64); + builder.HasIndex(x => x.TenantId); + } + + private static void ConfigureTenantSubscriptionHistory(EntityTypeBuilder builder) + { + builder.ToTable("tenant_subscription_histories"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.TenantSubscriptionId).IsRequired(); + builder.Property(x => x.Notes).HasMaxLength(512); + builder.Property(x => x.Currency).HasMaxLength(8); + builder.HasIndex(x => new { x.TenantId, x.TenantSubscriptionId }); + } + private static void ConfigureMerchant(EntityTypeBuilder builder) { builder.ToTable("merchants"); @@ -414,6 +475,35 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.Channel, x.SentAt }); } + private static void ConfigureTenantAnnouncement(EntityTypeBuilder builder) + { + builder.ToTable("tenant_announcements"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.Title).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Content).HasColumnType("text").IsRequired(); + builder.Property(x => x.AnnouncementType).HasConversion(); + builder.Property(x => x.Priority).IsRequired(); + builder.Property(x => x.IsActive).IsRequired(); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + builder.HasIndex(x => new { x.TenantId, x.AnnouncementType, x.IsActive }); + builder.HasIndex(x => new { x.TenantId, x.EffectiveFrom, x.EffectiveTo }); + } + + private static void ConfigureTenantAnnouncementRead(EntityTypeBuilder builder) + { + builder.ToTable("tenant_announcement_reads"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TenantId).IsRequired(); + builder.Property(x => x.AnnouncementId).IsRequired(); + builder.Property(x => x.UserId); + builder.Property(x => x.ReadAt).IsRequired(); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + builder.HasIndex(x => new { x.TenantId, x.AnnouncementId, x.UserId }).IsUnique(); + } + private static void ConfigureMerchantDocument(EntityTypeBuilder builder) { builder.ToTable("merchant_documents"); @@ -452,6 +542,27 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.MerchantId, x.Phone }); } + private static void ConfigureMerchantAuditLog(EntityTypeBuilder builder) + { + builder.ToTable("merchant_audit_logs"); + builder.HasKey(x => x.Id); + builder.Property(x => x.MerchantId).IsRequired(); + builder.Property(x => x.Title).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(1024); + builder.Property(x => x.OperatorName).HasMaxLength(64); + builder.HasIndex(x => new { x.TenantId, x.MerchantId }); + } + + private static void ConfigureMerchantCategory(EntityTypeBuilder builder) + { + builder.ToTable("merchant_categories"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.DisplayOrder).HasDefaultValue(0); + builder.Property(x => x.IsActive).IsRequired(); + builder.HasIndex(x => new { x.TenantId, x.Name }).IsUnique(); + } + private static void ConfigureStoreBusinessHour(EntityTypeBuilder builder) { builder.ToTable("store_business_hours"); @@ -515,6 +626,28 @@ public sealed class TakeoutAppDbContext( builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ShiftDate, x.StaffId }).IsUnique(); } + private static void ConfigureStorePickupSetting(EntityTypeBuilder builder) + { + builder.ToTable("store_pickup_settings"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.DefaultCutoffMinutes).HasDefaultValue(30); + builder.Property(x => x.RowVersion).IsRowVersion(); + builder.HasIndex(x => new { x.TenantId, x.StoreId }).IsUnique(); + } + + private static void ConfigureStorePickupSlot(EntityTypeBuilder builder) + { + builder.ToTable("store_pickup_slots"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Weekdays).HasMaxLength(32).IsRequired(); + builder.Property(x => x.CutoffMinutes).HasDefaultValue(30); + builder.Property(x => x.RowVersion).IsRowVersion(); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.Name }); + } + private static void ConfigureProductAttributeGroup(EntityTypeBuilder builder) { builder.ToTable("product_attribute_groups"); @@ -598,6 +731,7 @@ public sealed class TakeoutAppDbContext( builder.Property(x => x.ProductSkuId).IsRequired(); builder.Property(x => x.BatchNumber).HasMaxLength(64); builder.Property(x => x.Location).HasMaxLength(64); + builder.Property(x => x.RowVersion).IsRowVersion(); builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }); } @@ -618,9 +752,24 @@ public sealed class TakeoutAppDbContext( builder.Property(x => x.StoreId).IsRequired(); builder.Property(x => x.ProductSkuId).IsRequired(); builder.Property(x => x.BatchNumber).HasMaxLength(64).IsRequired(); + builder.Property(x => x.RowVersion).IsRowVersion(); builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.BatchNumber }).IsUnique(); } + private static void ConfigureInventoryLockRecord(EntityTypeBuilder builder) + { + builder.ToTable("inventory_lock_records"); + builder.HasKey(x => x.Id); + builder.Property(x => x.StoreId).IsRequired(); + builder.Property(x => x.ProductSkuId).IsRequired(); + builder.Property(x => x.Quantity).IsRequired(); + builder.Property(x => x.IdempotencyKey).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.RowVersion).IsRowVersion(); + builder.HasIndex(x => new { x.TenantId, x.IdempotencyKey }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.StoreId, x.ProductSkuId, x.Status }); + } + private static void ConfigureShoppingCart(EntityTypeBuilder builder) { builder.ToTable("shopping_carts"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs index 34917c7..3bc734d 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfDeliveryRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Deliveries.Entities; using TakeoutSaaS.Domain.Deliveries.Enums; @@ -15,8 +14,6 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfDeliveryRepository(TakeoutAppDbContext context) : IDeliveryRepository { - - /// public Task FindByIdAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfInventoryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfInventoryRepository.cs new file mode 100644 index 0000000..0cc6526 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfInventoryRepository.cs @@ -0,0 +1,145 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Inventory.Entities; +using TakeoutSaaS.Domain.Inventory.Enums; +using TakeoutSaaS.Domain.Inventory.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 库存仓储 EF 实现。 +/// +/// +/// 提供库存与批次的读写能力。 +/// +public sealed class EfInventoryRepository(TakeoutAppDbContext context) : IInventoryRepository +{ + /// + public Task FindByIdAsync(long inventoryItemId, long tenantId, CancellationToken cancellationToken = default) + { + return context.InventoryItems + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.Id == inventoryItemId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task FindBySkuAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default) + { + return context.InventoryItems + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task GetForUpdateAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default) + { + return context.InventoryItems + .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddItemAsync(InventoryItem item, CancellationToken cancellationToken = default) + { + return context.InventoryItems.AddAsync(item, cancellationToken).AsTask(); + } + + /// + public Task UpdateItemAsync(InventoryItem item, CancellationToken cancellationToken = default) + { + context.InventoryItems.Update(item); + return Task.CompletedTask; + } + + /// + public Task AddAdjustmentAsync(InventoryAdjustment adjustment, CancellationToken cancellationToken = default) + { + return context.InventoryAdjustments.AddAsync(adjustment, cancellationToken).AsTask(); + } + + /// + public Task AddLockAsync(InventoryLockRecord lockRecord, CancellationToken cancellationToken = default) + { + return context.InventoryLockRecords.AddAsync(lockRecord, cancellationToken).AsTask(); + } + + /// + public Task FindLockByKeyAsync(long tenantId, string idempotencyKey, CancellationToken cancellationToken = default) + { + return context.InventoryLockRecords + .Where(x => x.TenantId == tenantId && x.IdempotencyKey == idempotencyKey) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task MarkLockStatusAsync(InventoryLockRecord lockRecord, InventoryLockStatus status, CancellationToken cancellationToken = default) + { + lockRecord.Status = status; + context.InventoryLockRecords.Update(lockRecord); + return Task.CompletedTask; + } + + /// + public async Task> FindExpiredLocksAsync(long tenantId, DateTime utcNow, CancellationToken cancellationToken = default) + { + var locks = await context.InventoryLockRecords + .Where(x => x.TenantId == tenantId && x.Status == InventoryLockStatus.Locked && x.ExpiresAt != null && x.ExpiresAt <= utcNow) + .ToListAsync(cancellationToken); + return locks; + } + + /// + public async Task> GetBatchesForConsumeAsync(long tenantId, long storeId, long productSkuId, InventoryBatchConsumeStrategy strategy, CancellationToken cancellationToken = default) + { + var query = context.InventoryBatches + .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId); + + query = strategy == InventoryBatchConsumeStrategy.Fefo + ? query.OrderBy(x => x.ExpireDate ?? DateTime.MaxValue).ThenBy(x => x.BatchNumber) + : query.OrderBy(x => x.BatchNumber); + + return await query.ToListAsync(cancellationToken); + } + + /// + public async Task> GetBatchesAsync(long tenantId, long storeId, long productSkuId, CancellationToken cancellationToken = default) + { + var batches = await context.InventoryBatches + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId) + .OrderBy(x => x.ExpireDate ?? DateTime.MaxValue) + .ThenBy(x => x.BatchNumber) + .ToListAsync(cancellationToken); + + return batches; + } + + /// + public Task GetBatchForUpdateAsync(long tenantId, long storeId, long productSkuId, string batchNumber, CancellationToken cancellationToken = default) + { + return context.InventoryBatches + .Where(x => x.TenantId == tenantId && x.StoreId == storeId && x.ProductSkuId == productSkuId && x.BatchNumber == batchNumber) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default) + { + return context.InventoryBatches.AddAsync(batch, cancellationToken).AsTask(); + } + + /// + public Task UpdateBatchAsync(InventoryBatch batch, CancellationToken cancellationToken = default) + { + context.InventoryBatches.Update(batch); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs new file mode 100644 index 0000000..f832908 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantCategoryRepository.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Merchants.Entities; +using TakeoutSaaS.Domain.Merchants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 商户类目的 EF Core 仓储实现。 +/// +public sealed class EfMerchantCategoryRepository(TakeoutAppDbContext context) + : IMerchantCategoryRepository +{ + /// + public async Task> ListAsync(long tenantId, CancellationToken cancellationToken = default) + { + var items = await context.MerchantCategories + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderBy(x => x.DisplayOrder) + .ThenBy(x => x.CreatedAt) + .ToListAsync(cancellationToken); + + return items; + } + + /// + public Task ExistsAsync(string name, long tenantId, CancellationToken cancellationToken = default) + { + return context.MerchantCategories.AnyAsync( + x => x.TenantId == tenantId && x.Name == name, cancellationToken); + } + + /// + public Task FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default) + { + return context.MerchantCategories + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == id, cancellationToken); + } + + /// + public Task AddAsync(MerchantCategory category, CancellationToken cancellationToken = default) + { + return context.MerchantCategories.AddAsync(category, cancellationToken).AsTask(); + } + + /// + public Task RemoveAsync(MerchantCategory category, CancellationToken cancellationToken = default) + { + context.MerchantCategories.Remove(category); + return Task.CompletedTask; + } + + /// + public Task UpdateRangeAsync(IEnumerable categories, CancellationToken cancellationToken = default) + { + context.MerchantCategories.UpdateRange(categories); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs index 6158f92..34eeb92 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfMerchantRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Merchants.Entities; using TakeoutSaaS.Domain.Merchants.Enums; @@ -15,8 +14,6 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchantRepository { - - /// public Task FindByIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) { @@ -55,6 +52,26 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan return staffs; } + /// + public async Task> GetStaffByStoreAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var staffs = await context.MerchantStaff + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.Name) + .ToListAsync(cancellationToken); + + return staffs; + } + + /// + public Task FindStaffByIdAsync(long staffId, long tenantId, CancellationToken cancellationToken = default) + { + return context.MerchantStaff + .Where(x => x.TenantId == tenantId && x.Id == staffId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) { @@ -67,6 +84,14 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan return contracts; } + /// + public Task FindContractByIdAsync(long merchantId, long tenantId, long contractId, CancellationToken cancellationToken = default) + { + return context.MerchantContracts + .Where(x => x.TenantId == tenantId && x.MerchantId == merchantId && x.Id == contractId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) { @@ -79,6 +104,14 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan return documents; } + /// + public Task FindDocumentByIdAsync(long merchantId, long tenantId, long documentId, CancellationToken cancellationToken = default) + { + return context.MerchantDocuments + .Where(x => x.TenantId == tenantId && x.MerchantId == merchantId && x.Id == documentId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public Task AddMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default) { @@ -97,12 +130,26 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan return context.MerchantContracts.AddAsync(contract, cancellationToken).AsTask(); } + /// + public Task UpdateContractAsync(MerchantContract contract, CancellationToken cancellationToken = default) + { + context.MerchantContracts.Update(contract); + return Task.CompletedTask; + } + /// public Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default) { return context.MerchantDocuments.AddAsync(document, cancellationToken).AsTask(); } + /// + public Task UpdateDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default) + { + context.MerchantDocuments.Update(document); + return Task.CompletedTask; + } + /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) { @@ -130,4 +177,35 @@ public sealed class EfMerchantRepository(TakeoutAppDbContext context) : IMerchan context.Merchants.Remove(existing); } + + /// + public async Task DeleteStaffAsync(long staffId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.MerchantStaff + .Where(x => x.TenantId == tenantId && x.Id == staffId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing == null) + { + return; + } + + context.MerchantStaff.Remove(existing); + } + + /// + public Task AddAuditLogAsync(MerchantAuditLog log, CancellationToken cancellationToken = default) + { + return context.MerchantAuditLogs.AddAsync(log, cancellationToken).AsTask(); + } + + /// + public async Task> GetAuditLogsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default) + { + return await context.MerchantAuditLogs + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.MerchantId == merchantId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs index c73a185..ba663d4 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfOrderRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Orders.Entities; using TakeoutSaaS.Domain.Orders.Enums; @@ -16,8 +15,6 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfOrderRepository(TakeoutAppDbContext context) : IOrderRepository { - - /// public Task FindByIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs index 90be2a6..1b6ea8d 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfPaymentRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Payments.Entities; using TakeoutSaaS.Domain.Payments.Enums; @@ -15,8 +14,6 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfPaymentRepository(TakeoutAppDbContext context) : IPaymentRepository { - - /// public Task FindByIdAsync(long paymentId, long tenantId, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs index 65666bb..5b49b68 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfProductRepository.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Products.Entities; @@ -15,8 +16,6 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductRepository { - - /// public Task FindByIdAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -27,12 +26,17 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR } /// - public async Task> SearchAsync(long tenantId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default) + public async Task> SearchAsync(long tenantId, long? storeId, long? categoryId, ProductStatus? status, CancellationToken cancellationToken = default, DateTime? updatedAfter = null) { var query = context.Products .AsNoTracking() .Where(x => x.TenantId == tenantId); + if (storeId.HasValue) + { + query = query.Where(x => x.StoreId == storeId.Value); + } + if (categoryId.HasValue) { query = query.Where(x => x.CategoryId == categoryId.Value); @@ -43,6 +47,11 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR query = query.Where(x => x.Status == status.Value); } + if (updatedAfter.HasValue) + { + query = query.Where(x => (x.UpdatedAt ?? x.CreatedAt) >= updatedAfter.Value); + } + var products = await query .OrderBy(x => x.Name) .ToListAsync(cancellationToken); @@ -62,6 +71,22 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return categories; } + /// + public async Task> GetCategoriesByStoreAsync(long tenantId, long storeId, bool onlyEnabled = true, CancellationToken cancellationToken = default) + { + var query = context.ProductCategories + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId); + if (onlyEnabled) + { + query = query.Where(x => x.IsEnabled); + } + var categories = await query + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return categories; + } + /// public async Task> GetSkusAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -74,6 +99,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return skus; } + /// + public async Task> GetSkusByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var skus = await context.ProductSkus + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return skus; + } + /// public async Task> GetAddonGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -86,6 +126,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return groups; } + /// + public async Task> GetAddonGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var groups = await context.ProductAddonGroups + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return groups; + } + /// public async Task> GetAddonOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -109,6 +164,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return options; } + /// + public async Task> GetAddonOptionsByGroupIdsAsync(IReadOnlyCollection addonGroupIds, long tenantId, CancellationToken cancellationToken = default) + { + if (addonGroupIds.Count == 0) + { + return Array.Empty(); + } + var options = await context.ProductAddonOptions + .AsNoTracking() + .Where(x => x.TenantId == tenantId && addonGroupIds.Contains(x.AddonGroupId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return options; + } + /// public async Task> GetAttributeGroupsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -121,6 +191,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return groups; } + /// + public async Task> GetAttributeGroupsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var groups = await context.ProductAttributeGroups + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return groups; + } + /// public async Task> GetAttributeOptionsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -144,6 +229,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return options; } + /// + public async Task> GetAttributeOptionsByGroupIdsAsync(IReadOnlyCollection attributeGroupIds, long tenantId, CancellationToken cancellationToken = default) + { + if (attributeGroupIds.Count == 0) + { + return Array.Empty(); + } + var options = await context.ProductAttributeOptions + .AsNoTracking() + .Where(x => x.TenantId == tenantId && attributeGroupIds.Contains(x.AttributeGroupId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return options; + } + /// public async Task> GetMediaAssetsAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -156,6 +256,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return assets; } + /// + public async Task> GetMediaAssetsByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var assets = await context.ProductMediaAssets + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return assets; + } + /// public async Task> GetPricingRulesAsync(long productId, long tenantId, CancellationToken cancellationToken = default) { @@ -168,6 +283,21 @@ public sealed class EfProductRepository(TakeoutAppDbContext context) : IProductR return rules; } + /// + public async Task> GetPricingRulesByProductIdsAsync(IReadOnlyCollection productIds, long tenantId, CancellationToken cancellationToken = default) + { + if (productIds.Count == 0) + { + return Array.Empty(); + } + var rules = await context.ProductPricingRules + .AsNoTracking() + .Where(x => x.TenantId == tenantId && productIds.Contains(x.ProductId)) + .OrderBy(x => x.SortOrder) + .ToListAsync(cancellationToken); + return rules; + } + /// public Task AddCategoryAsync(ProductCategory category, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs index 3a0934a..23c67ce 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfStoreRepository.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Stores.Entities; @@ -15,8 +16,6 @@ namespace TakeoutSaaS.Infrastructure.App.Repositories; /// public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepository { - - /// public Task FindByIdAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) { @@ -58,6 +57,14 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return hours; } + /// + public Task FindBusinessHourByIdAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreBusinessHours + .Where(x => x.TenantId == tenantId && x.Id == businessHourId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetDeliveryZonesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) { @@ -70,6 +77,14 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return zones; } + /// + public Task FindDeliveryZoneByIdAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreDeliveryZones + .Where(x => x.TenantId == tenantId && x.Id == deliveryZoneId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetHolidaysAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) { @@ -82,6 +97,14 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return holidays; } + /// + public Task FindHolidayByIdAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreHolidays + .Where(x => x.TenantId == tenantId && x.Id == holidayId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetTableAreasAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) { @@ -94,6 +117,14 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return areas; } + /// + public Task FindTableAreaByIdAsync(long areaId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreTableAreas + .Where(x => x.TenantId == tenantId && x.Id == areaId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetTablesAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) { @@ -107,11 +138,92 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos } /// - public async Task> GetShiftsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + public Task FindTableByIdAsync(long tableId, long tenantId, CancellationToken cancellationToken = default) { - var shifts = await context.StoreEmployeeShifts + return context.StoreTables + .Where(x => x.TenantId == tenantId && x.Id == tableId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task FindTableByCodeAsync(string tableCode, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreTables + .Where(x => x.TenantId == tenantId && x.TableCode == tableCode) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task GetPickupSettingAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StorePickupSettings + .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddPickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default) + { + return context.StorePickupSettings.AddAsync(setting, cancellationToken).AsTask(); + } + + /// + public Task UpdatePickupSettingAsync(StorePickupSetting setting, CancellationToken cancellationToken = default) + { + context.StorePickupSettings.Update(setting); + return Task.CompletedTask; + } + + /// + public async Task> GetPickupSlotsAsync(long storeId, long tenantId, CancellationToken cancellationToken = default) + { + var slots = await context.StorePickupSlots .AsNoTracking() .Where(x => x.TenantId == tenantId && x.StoreId == storeId) + .OrderBy(x => x.StartTime) + .ToListAsync(cancellationToken); + return slots; + } + + /// + public Task FindPickupSlotByIdAsync(long slotId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StorePickupSlots + .Where(x => x.TenantId == tenantId && x.Id == slotId) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task AddPickupSlotsAsync(IEnumerable slots, CancellationToken cancellationToken = default) + { + return context.StorePickupSlots.AddRangeAsync(slots, cancellationToken); + } + + /// + public Task UpdatePickupSlotAsync(StorePickupSlot slot, CancellationToken cancellationToken = default) + { + context.StorePickupSlots.Update(slot); + return Task.CompletedTask; + } + + /// + public async Task> GetShiftsAsync(long storeId, long tenantId, DateTime? from = null, DateTime? to = null, CancellationToken cancellationToken = default) + { + var query = context.StoreEmployeeShifts + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.StoreId == storeId); + + if (from.HasValue) + { + query = query.Where(x => x.ShiftDate >= from.Value.Date); + } + + if (to.HasValue) + { + query = query.Where(x => x.ShiftDate <= to.Value.Date); + } + + var shifts = await query .OrderBy(x => x.ShiftDate) .ThenBy(x => x.StartTime) .ToListAsync(cancellationToken); @@ -119,6 +231,14 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return shifts; } + /// + public Task FindShiftByIdAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default) + { + return context.StoreEmployeeShifts + .Where(x => x.TenantId == tenantId && x.Id == shiftId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public Task AddStoreAsync(Store store, CancellationToken cancellationToken = default) { @@ -131,42 +251,175 @@ public sealed class EfStoreRepository(TakeoutAppDbContext context) : IStoreRepos return context.StoreBusinessHours.AddRangeAsync(hours, cancellationToken); } + /// + public Task UpdateBusinessHourAsync(StoreBusinessHour hour, CancellationToken cancellationToken = default) + { + context.StoreBusinessHours.Update(hour); + return Task.CompletedTask; + } + /// public Task AddDeliveryZonesAsync(IEnumerable zones, CancellationToken cancellationToken = default) { return context.StoreDeliveryZones.AddRangeAsync(zones, cancellationToken); } + /// + public Task UpdateDeliveryZoneAsync(StoreDeliveryZone zone, CancellationToken cancellationToken = default) + { + context.StoreDeliveryZones.Update(zone); + return Task.CompletedTask; + } + /// public Task AddHolidaysAsync(IEnumerable holidays, CancellationToken cancellationToken = default) { return context.StoreHolidays.AddRangeAsync(holidays, cancellationToken); } + /// + public Task UpdateHolidayAsync(StoreHoliday holiday, CancellationToken cancellationToken = default) + { + context.StoreHolidays.Update(holiday); + return Task.CompletedTask; + } + /// public Task AddTableAreasAsync(IEnumerable areas, CancellationToken cancellationToken = default) { return context.StoreTableAreas.AddRangeAsync(areas, cancellationToken); } + /// + public Task UpdateTableAreaAsync(StoreTableArea area, CancellationToken cancellationToken = default) + { + context.StoreTableAreas.Update(area); + return Task.CompletedTask; + } + /// public Task AddTablesAsync(IEnumerable tables, CancellationToken cancellationToken = default) { return context.StoreTables.AddRangeAsync(tables, cancellationToken); } + /// + public Task UpdateTableAsync(StoreTable table, CancellationToken cancellationToken = default) + { + context.StoreTables.Update(table); + return Task.CompletedTask; + } + /// public Task AddShiftsAsync(IEnumerable shifts, CancellationToken cancellationToken = default) { return context.StoreEmployeeShifts.AddRangeAsync(shifts, cancellationToken); } + /// + public Task UpdateShiftAsync(StoreEmployeeShift shift, CancellationToken cancellationToken = default) + { + context.StoreEmployeeShifts.Update(shift); + return Task.CompletedTask; + } + /// public Task SaveChangesAsync(CancellationToken cancellationToken = default) { return context.SaveChangesAsync(cancellationToken); } + /// + public async Task DeleteBusinessHourAsync(long businessHourId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreBusinessHours + .Where(x => x.TenantId == tenantId && x.Id == businessHourId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreBusinessHours.Remove(existing); + } + } + + /// + public async Task DeleteDeliveryZoneAsync(long deliveryZoneId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreDeliveryZones + .Where(x => x.TenantId == tenantId && x.Id == deliveryZoneId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreDeliveryZones.Remove(existing); + } + } + + /// + public async Task DeleteHolidayAsync(long holidayId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreHolidays + .Where(x => x.TenantId == tenantId && x.Id == holidayId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreHolidays.Remove(existing); + } + } + + /// + public async Task DeleteTableAreaAsync(long areaId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreTableAreas + .Where(x => x.TenantId == tenantId && x.Id == areaId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreTableAreas.Remove(existing); + } + } + + /// + public async Task DeleteTableAsync(long tableId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreTables + .Where(x => x.TenantId == tenantId && x.Id == tableId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreTables.Remove(existing); + } + } + + /// + public async Task DeletePickupSlotAsync(long slotId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StorePickupSlots + .Where(x => x.TenantId == tenantId && x.Id == slotId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StorePickupSlots.Remove(existing); + } + } + + /// + public async Task DeleteShiftAsync(long shiftId, long tenantId, CancellationToken cancellationToken = default) + { + var existing = await context.StoreEmployeeShifts + .Where(x => x.TenantId == tenantId && x.Id == shiftId) + .FirstOrDefaultAsync(cancellationToken); + + if (existing != null) + { + context.StoreEmployeeShifts.Remove(existing); + } + } + /// public Task UpdateStoreAsync(Store store, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs new file mode 100644 index 0000000..23236ae --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementReadRepository.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// EF 公告已读仓储。 +/// +public sealed class EfTenantAnnouncementReadRepository(TakeoutAppDbContext context) : ITenantAnnouncementReadRepository +{ + /// + public Task> GetByAnnouncementAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncementReads.AsNoTracking() + .Where(x => x.TenantId == tenantId && x.AnnouncementId == announcementId) + .OrderBy(x => x.ReadAt) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + /// + public Task> GetByAnnouncementAsync(long tenantId, IEnumerable announcementIds, long? userId, CancellationToken cancellationToken = default) + { + var ids = announcementIds.Distinct().ToArray(); + if (ids.Length == 0) + { + return Task.FromResult>(Array.Empty()); + } + + var query = context.TenantAnnouncementReads.AsNoTracking() + .Where(x => x.TenantId == tenantId && ids.Contains(x.AnnouncementId)); + + if (userId.HasValue) + { + query = query.Where(x => x.UserId == userId.Value); + } + else + { + query = query.Where(x => x.UserId == null); + } + + return query + .OrderByDescending(x => x.ReadAt) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + /// + public Task FindAsync(long tenantId, long announcementId, long? userId, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncementReads + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.AnnouncementId == announcementId && x.UserId == userId, cancellationToken); + } + + /// + public Task AddAsync(TenantAnnouncementRead record, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncementReads.AddAsync(record, cancellationToken).AsTask(); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs new file mode 100644 index 0000000..5ed5afd --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantAnnouncementRepository.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// EF 租户公告仓储。 +/// +public sealed class EfTenantAnnouncementRepository(TakeoutAppDbContext context) : ITenantAnnouncementRepository +{ + /// + public Task> SearchAsync( + long tenantId, + TenantAnnouncementType? type, + bool? isActive, + DateTime? effectiveAt, + CancellationToken cancellationToken = default) + { + var query = context.TenantAnnouncements.AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (type.HasValue) + { + query = query.Where(x => x.AnnouncementType == type.Value); + } + + if (isActive.HasValue) + { + query = query.Where(x => x.IsActive == isActive.Value); + } + + if (effectiveAt.HasValue) + { + var at = effectiveAt.Value; + query = query.Where(x => x.EffectiveFrom <= at && (x.EffectiveTo == null || x.EffectiveTo >= at)); + } + + return query + .OrderByDescending(x => x.Priority) + .ThenByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + /// + public Task FindByIdAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncements.AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken); + } + + /// + public Task AddAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default) + { + return context.TenantAnnouncements.AddAsync(announcement, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(TenantAnnouncement announcement, CancellationToken cancellationToken = default) + { + context.TenantAnnouncements.Update(announcement); + return Task.CompletedTask; + } + + /// + public async Task DeleteAsync(long tenantId, long announcementId, CancellationToken cancellationToken = default) + { + var entity = await context.TenantAnnouncements.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == announcementId, cancellationToken); + if (entity != null) + { + context.TenantAnnouncements.Remove(entity); + } + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs new file mode 100644 index 0000000..7b939d5 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantBillingRepository.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// EF 租户账单仓储。 +/// +public sealed class EfTenantBillingRepository(TakeoutAppDbContext context) : ITenantBillingRepository +{ + /// + public Task> SearchAsync( + long tenantId, + TenantBillingStatus? status, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default) + { + var query = context.TenantBillingStatements.AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + if (from.HasValue) + { + query = query.Where(x => x.PeriodStart >= from.Value); + } + + if (to.HasValue) + { + query = query.Where(x => x.PeriodEnd <= to.Value); + } + + return query + .OrderByDescending(x => x.PeriodEnd) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + /// + public Task FindByIdAsync(long tenantId, long billingId, CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements.AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == billingId, cancellationToken); + } + + /// + public Task FindByStatementNoAsync(long tenantId, string statementNo, CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements.AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.StatementNo == statementNo, cancellationToken); + } + + /// + public Task AddAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) + { + return context.TenantBillingStatements.AddAsync(bill, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(TenantBillingStatement bill, CancellationToken cancellationToken = default) + { + context.TenantBillingStatements.Update(bill); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs new file mode 100644 index 0000000..27417cd --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantNotificationRepository.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// EF 租户通知仓储。 +/// +public sealed class EfTenantNotificationRepository(TakeoutAppDbContext context) : ITenantNotificationRepository +{ + /// + public Task> SearchAsync( + long tenantId, + TenantNotificationSeverity? severity, + bool? unreadOnly, + DateTime? from, + DateTime? to, + CancellationToken cancellationToken = default) + { + var query = context.TenantNotifications.AsNoTracking() + .Where(x => x.TenantId == tenantId); + + if (severity.HasValue) + { + query = query.Where(x => x.Severity == severity.Value); + } + + if (unreadOnly == true) + { + query = query.Where(x => x.ReadAt == null); + } + + if (from.HasValue) + { + query = query.Where(x => x.SentAt >= from.Value); + } + + if (to.HasValue) + { + query = query.Where(x => x.SentAt <= to.Value); + } + + return query + .OrderByDescending(x => x.SentAt) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + /// + public Task FindByIdAsync(long tenantId, long notificationId, CancellationToken cancellationToken = default) + { + return context.TenantNotifications + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == notificationId, cancellationToken); + } + + /// + public Task AddAsync(TenantNotification notification, CancellationToken cancellationToken = default) + { + return context.TenantNotifications.AddAsync(notification, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(TenantNotification notification, CancellationToken cancellationToken = default) + { + context.TenantNotifications.Update(notification); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs new file mode 100644 index 0000000..d23b429 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantPackageRepository.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 租户套餐仓储实现。 +/// +public sealed class EfTenantPackageRepository(TakeoutAppDbContext context) : ITenantPackageRepository +{ + /// + public Task FindByIdAsync(long id, CancellationToken cancellationToken = default) + { + return context.TenantPackages.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + } + + /// + public async Task> SearchAsync(string? keyword, bool? isActive, CancellationToken cancellationToken = default) + { + var query = context.TenantPackages.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(keyword)) + { + var normalized = keyword.Trim(); + query = query.Where(x => EF.Functions.ILike(x.Name, $"%{normalized}%") || EF.Functions.ILike(x.Description ?? string.Empty, $"%{normalized}%")); + } + + if (isActive.HasValue) + { + query = query.Where(x => x.IsActive == isActive.Value); + } + + return await query + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public Task AddAsync(TenantPackage package, CancellationToken cancellationToken = default) + { + return context.TenantPackages.AddAsync(package, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(TenantPackage package, CancellationToken cancellationToken = default) + { + context.TenantPackages.Update(package); + return Task.CompletedTask; + } + + /// + public async Task DeleteAsync(long id, CancellationToken cancellationToken = default) + { + var entity = await context.TenantPackages.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (entity != null) + { + context.TenantPackages.Remove(entity); + } + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs new file mode 100644 index 0000000..dd9c564 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantQuotaUsageRepository.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 租户配额使用仓储实现。 +/// +public sealed class EfTenantQuotaUsageRepository(TakeoutAppDbContext context) : ITenantQuotaUsageRepository +{ + /// + public Task FindAsync(long tenantId, TenantQuotaType quotaType, CancellationToken cancellationToken = default) + { + return context.TenantQuotaUsages + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.QuotaType == quotaType, cancellationToken); + } + + /// + public Task> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default) + { + return context.TenantQuotaUsages + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderBy(x => x.QuotaType) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + /// + public Task AddAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default) + { + return context.TenantQuotaUsages.AddAsync(usage, cancellationToken).AsTask(); + } + + /// + public Task UpdateAsync(TenantQuotaUsage usage, CancellationToken cancellationToken = default) + { + context.TenantQuotaUsages.Update(usage); + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs new file mode 100644 index 0000000..850046d --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs @@ -0,0 +1,160 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Enums; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.App.Persistence; + +namespace TakeoutSaaS.Infrastructure.App.Repositories; + +/// +/// 租户聚合的 EF Core 仓储实现。 +/// +public sealed class EfTenantRepository(TakeoutAppDbContext context) : ITenantRepository +{ + /// + public Task FindByIdAsync(long tenantId, CancellationToken cancellationToken = default) + { + return context.Tenants + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == tenantId, cancellationToken); + } + + /// + public async Task> SearchAsync( + TenantStatus? status, + string? keyword, + CancellationToken cancellationToken = default) + { + var query = context.Tenants.AsNoTracking(); + + if (status.HasValue) + { + query = query.Where(x => x.Status == status.Value); + } + + if (!string.IsNullOrWhiteSpace(keyword)) + { + keyword = keyword.Trim(); + query = query.Where(x => + EF.Functions.ILike(x.Name, $"%{keyword}%") || + EF.Functions.ILike(x.Code, $"%{keyword}%") || + EF.Functions.ILike(x.ContactName ?? string.Empty, $"%{keyword}%")); + } + + return await query + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public Task AddTenantAsync(Tenant tenant, CancellationToken cancellationToken = default) + { + return context.Tenants.AddAsync(tenant, cancellationToken).AsTask(); + } + + /// + public Task UpdateTenantAsync(Tenant tenant, CancellationToken cancellationToken = default) + { + context.Tenants.Update(tenant); + return Task.CompletedTask; + } + + /// + public Task ExistsByCodeAsync(string code, CancellationToken cancellationToken = default) + { + var normalized = code.Trim(); + return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken); + } + + /// + public Task GetVerificationProfileAsync(long tenantId, CancellationToken cancellationToken = default) + { + return context.TenantVerificationProfiles + .AsNoTracking() + .FirstOrDefaultAsync(x => x.TenantId == tenantId, cancellationToken); + } + + /// + public async Task UpsertVerificationProfileAsync(TenantVerificationProfile profile, CancellationToken cancellationToken = default) + { + var existing = await context.TenantVerificationProfiles + .FirstOrDefaultAsync(x => x.TenantId == profile.TenantId, cancellationToken); + + if (existing == null) + { + await context.TenantVerificationProfiles.AddAsync(profile, cancellationToken); + return; + } + + profile.Id = existing.Id; + context.Entry(existing).CurrentValues.SetValues(profile); + } + + /// + public Task GetActiveSubscriptionAsync(long tenantId, CancellationToken cancellationToken = default) + { + return context.TenantSubscriptions + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderByDescending(x => x.EffectiveTo) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public Task FindSubscriptionByIdAsync(long tenantId, long subscriptionId, CancellationToken cancellationToken = default) + { + return context.TenantSubscriptions + .FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Id == subscriptionId, cancellationToken); + } + + /// + public Task AddSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default) + { + return context.TenantSubscriptions.AddAsync(subscription, cancellationToken).AsTask(); + } + + /// + public Task UpdateSubscriptionAsync(TenantSubscription subscription, CancellationToken cancellationToken = default) + { + context.TenantSubscriptions.Update(subscription); + return Task.CompletedTask; + } + + /// + public Task AddSubscriptionHistoryAsync(TenantSubscriptionHistory history, CancellationToken cancellationToken = default) + { + return context.TenantSubscriptionHistories.AddAsync(history, cancellationToken).AsTask(); + } + + /// + public async Task> GetSubscriptionHistoryAsync(long tenantId, CancellationToken cancellationToken = default) + { + return await context.TenantSubscriptionHistories + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderByDescending(x => x.EffectiveFrom) + .ToListAsync(cancellationToken); + } + + /// + public Task AddAuditLogAsync(TenantAuditLog log, CancellationToken cancellationToken = default) + { + return context.TenantAuditLogs.AddAsync(log, cancellationToken).AsTask(); + } + + /// + public async Task> GetAuditLogsAsync(long tenantId, CancellationToken cancellationToken = default) + { + return await context.TenantAuditLogs + .AsNoTracking() + .Where(x => x.TenantId == tenantId) + .OrderByDescending(x => x.CreatedAt) + .ToListAsync(cancellationToken); + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return context.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs index 4f50569..e9940a0 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Extensions/DatabaseServiceCollectionExtensions.cs @@ -3,8 +3,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Infrastructure.Common.Options; using TakeoutSaaS.Infrastructure.Common.Persistence; -using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Abstractions.Data; +using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Kernel.Ids; namespace TakeoutSaaS.Infrastructure.Common.Extensions; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs index 4733274..1f06200 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/AppDbContext.cs @@ -1,9 +1,9 @@ -using System.Reflection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using System.Reflection; using TakeoutSaaS.Shared.Abstractions.Entities; -using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Ids; +using TakeoutSaaS.Shared.Abstractions.Security; namespace TakeoutSaaS.Infrastructure.Common.Persistence; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs index e0761aa..d9defe1 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DapperExecutor.cs @@ -1,6 +1,6 @@ -using System.Data; using Microsoft.Extensions.Logging; using Npgsql; +using System.Data; using TakeoutSaaS.Shared.Abstractions.Data; namespace TakeoutSaaS.Infrastructure.Common.Persistence; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs index 7852aaa..54fee08 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DatabaseConnectionFactory.cs @@ -1,9 +1,8 @@ -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Security.Cryptography; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography; using TakeoutSaaS.Infrastructure.Common.Options; using TakeoutSaaS.Shared.Abstractions.Data; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DesignTime/DesignTimeDbContextFactoryBase.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DesignTime/DesignTimeDbContextFactoryBase.cs index 5a2cf5f..a2dcba9 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DesignTime/DesignTimeDbContextFactoryBase.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/DesignTime/DesignTimeDbContextFactoryBase.cs @@ -1,6 +1,3 @@ -using System; -using System.IO; -using System.Linq; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs index e143c8f..193409c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/ModelBuilderCommentExtensions.cs @@ -1,8 +1,8 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; using System.Collections.Concurrent; using System.Reflection; using System.Xml.Linq; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; namespace TakeoutSaaS.Infrastructure.Common.Persistence; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs index f743d24..66e0f41 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs @@ -1,9 +1,9 @@ -using System.Reflection; using Microsoft.EntityFrameworkCore; +using System.Reflection; using TakeoutSaaS.Shared.Abstractions.Entities; +using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; -using TakeoutSaaS.Shared.Abstractions.Ids; namespace TakeoutSaaS.Infrastructure.Common.Persistence; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs index 41cc530..0055d45 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs @@ -1,16 +1,14 @@ -using System; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Application.Dictionary.Abstractions; using TakeoutSaaS.Domain.Dictionary.Repositories; +using TakeoutSaaS.Domain.SystemParameters.Repositories; using TakeoutSaaS.Infrastructure.Common.Extensions; -using TakeoutSaaS.Infrastructure.Common.Options; using TakeoutSaaS.Infrastructure.Dictionary.Options; using TakeoutSaaS.Infrastructure.Dictionary.Persistence; using TakeoutSaaS.Infrastructure.Dictionary.Repositories; using TakeoutSaaS.Infrastructure.Dictionary.Services; using TakeoutSaaS.Shared.Abstractions.Constants; -using TakeoutSaaS.Domain.SystemParameters.Repositories; namespace TakeoutSaaS.Infrastructure.Dictionary.Extensions; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs index 232ad67..4ce7005 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfDictionaryRepository.cs @@ -1,9 +1,8 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; -using TakeoutSaaS.Infrastructure.Dictionary.Persistence; using TakeoutSaaS.Domain.Dictionary.Entities; using TakeoutSaaS.Domain.Dictionary.Enums; using TakeoutSaaS.Domain.Dictionary.Repositories; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories; @@ -12,7 +11,6 @@ namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories; /// public sealed class EfDictionaryRepository(DictionaryDbContext context) : IDictionaryRepository { - public Task FindGroupByIdAsync(long id, CancellationToken cancellationToken = default) => context.DictionaryGroups.FirstOrDefaultAsync(group => group.Id == id, cancellationToken); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfSystemParameterRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfSystemParameterRepository.cs index 1ec7868..d7e515c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfSystemParameterRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/EfSystemParameterRepository.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.SystemParameters.Entities; using TakeoutSaaS.Domain.SystemParameters.Repositories; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs index 372c467..4897f82 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Services/DistributedDictionaryCache.cs @@ -1,6 +1,6 @@ -using System.Text.Json; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; +using System.Text.Json; using TakeoutSaaS.Application.Dictionary.Abstractions; using TakeoutSaaS.Application.Dictionary.Models; using TakeoutSaaS.Infrastructure.Dictionary.Options; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs index 79c475c..b346321 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/JwtAuthenticationExtensions.cs @@ -1,11 +1,10 @@ -using System; -using System.IdentityModel.Tokens.Jwt; -using System.Security.Claims; -using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; using TakeoutSaaS.Infrastructure.Identity.Options; namespace TakeoutSaaS.Infrastructure.Identity.Extensions; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs index 0c8dc63..ef2fd47 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Extensions/ServiceCollectionExtensions.cs @@ -1,12 +1,9 @@ -using System; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using TakeoutSaaS.Application.Identity.Abstractions; -using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; using TakeoutSaaS.Infrastructure.Common.Extensions; -using TakeoutSaaS.Infrastructure.Common.Options; using TakeoutSaaS.Infrastructure.Identity.Options; using TakeoutSaaS.Infrastructure.Identity.Persistence; using TakeoutSaaS.Infrastructure.Identity.Services; @@ -55,6 +52,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs index 5a2f813..31ee1cf 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs @@ -11,6 +11,11 @@ public sealed class AdminSeedOptions /// 初始用户列表。 /// public List Users { get; set; } = new(); + + /// + /// 角色模板种子列表。 + /// + public List RoleTemplates { get; set; } = new(); } /// @@ -56,3 +61,36 @@ public sealed class SeedUserOptions /// public string[] Permissions { get; set; } = Array.Empty(); } + +/// +/// 角色模板种子配置。 +/// +public sealed class RoleTemplateSeedOptions +{ + /// + /// 模板编码。 + /// + [Required] + public string TemplateCode { get; set; } = string.Empty; + + /// + /// 模板名称。 + /// + [Required] + public string Name { get; set; } = string.Empty; + + /// + /// 模板描述。 + /// + public string? Description { get; set; } + + /// + /// 是否启用。 + /// + public bool IsActive { get; set; } = true; + + /// + /// 权限编码集合。 + /// + public string[] Permissions { get; set; } = Array.Empty(); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs index 1f2bc95..487428f 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; @@ -13,7 +9,6 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence; /// public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIdentityUserRepository { - public Task FindByAccountAsync(string account, CancellationToken cancellationToken = default) => dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Account == account, cancellationToken); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs index 3276793..78c3d52 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfMiniUserRepository.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; @@ -12,7 +9,6 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence; /// public sealed class EfMiniUserRepository(IdentityDbContext dbContext) : IMiniUserRepository { - public Task FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default) => dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs index c07f15d..b8dbafc 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs @@ -15,6 +15,20 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi public Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default) => dbContext.Permissions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId, cancellationToken); + public Task> GetByCodesAsync(long tenantId, IEnumerable codes, CancellationToken cancellationToken = default) + { + var normalizedCodes = codes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct() + .ToArray(); + + return dbContext.Permissions.AsNoTracking() + .Where(x => x.TenantId == tenantId && normalizedCodes.Contains(x.Code)) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + public Task> GetByIdsAsync(long tenantId, IEnumerable permissionIds, CancellationToken cancellationToken = default) => dbContext.Permissions.AsNoTracking() .Where(x => x.TenantId == tenantId && permissionIds.Contains(x.Id)) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs index 0409fff..0ba4322 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs @@ -15,6 +15,17 @@ public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IR .ToListAsync(cancellationToken) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + public async Task AddRangeAsync(IEnumerable rolePermissions, CancellationToken cancellationToken = default) + { + var toAdd = rolePermissions as RolePermission[] ?? rolePermissions.ToArray(); + if (toAdd.Length == 0) + { + return; + } + + await dbContext.RolePermissions.AddRangeAsync(toAdd, cancellationToken); + } + public async Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable permissionIds, CancellationToken cancellationToken = default) { var existing = await dbContext.RolePermissions diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleTemplateRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleTemplateRepository.cs new file mode 100644 index 0000000..2f77700 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleTemplateRepository.cs @@ -0,0 +1,115 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Repositories; + +namespace TakeoutSaaS.Infrastructure.Identity.Persistence; + +/// +/// 角色模板仓储实现。 +/// +public sealed class EfRoleTemplateRepository(IdentityDbContext dbContext) : IRoleTemplateRepository +{ + public Task> GetAllAsync(bool? isActive, CancellationToken cancellationToken = default) + { + var query = dbContext.RoleTemplates.AsNoTracking(); + if (isActive.HasValue) + { + query = query.Where(x => x.IsActive == isActive.Value); + } + + return query + .OrderBy(x => x.TemplateCode) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public Task FindByCodeAsync(string templateCode, CancellationToken cancellationToken = default) + { + var normalized = templateCode.Trim(); + return dbContext.RoleTemplates.AsNoTracking().FirstOrDefaultAsync(x => x.TemplateCode == normalized, cancellationToken); + } + + public Task> GetPermissionsAsync(long roleTemplateId, CancellationToken cancellationToken = default) + { + return dbContext.RoleTemplatePermissions.AsNoTracking() + .Where(x => x.RoleTemplateId == roleTemplateId) + .ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + + public async Task>> GetPermissionsAsync(IEnumerable roleTemplateIds, CancellationToken cancellationToken = default) + { + var ids = roleTemplateIds.Distinct().ToArray(); + if (ids.Length == 0) + { + return new Dictionary>(); + } + + var permissions = await dbContext.RoleTemplatePermissions.AsNoTracking() + .Where(x => ids.Contains(x.RoleTemplateId)) + .ToListAsync(cancellationToken); + + return permissions + .GroupBy(x => x.RoleTemplateId) + .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); + } + + public async Task AddAsync(RoleTemplate template, IEnumerable permissionCodes, CancellationToken cancellationToken = default) + { + template.TemplateCode = template.TemplateCode.Trim(); + template.Name = template.Name.Trim(); + await dbContext.RoleTemplates.AddAsync(template, cancellationToken); + await ReplacePermissionsInternalAsync(template, permissionCodes, cancellationToken); + } + + public async Task UpdateAsync(RoleTemplate template, IEnumerable permissionCodes, CancellationToken cancellationToken = default) + { + template.TemplateCode = template.TemplateCode.Trim(); + template.Name = template.Name.Trim(); + dbContext.RoleTemplates.Update(template); + await ReplacePermissionsInternalAsync(template, permissionCodes, cancellationToken); + } + + public async Task DeleteAsync(long roleTemplateId, CancellationToken cancellationToken = default) + { + var entity = await dbContext.RoleTemplates.FirstOrDefaultAsync(x => x.Id == roleTemplateId, cancellationToken); + if (entity != null) + { + var permissions = dbContext.RoleTemplatePermissions.Where(x => x.RoleTemplateId == roleTemplateId); + dbContext.RoleTemplatePermissions.RemoveRange(permissions); + dbContext.RoleTemplates.Remove(entity); + } + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => dbContext.SaveChangesAsync(cancellationToken); + + private async Task ReplacePermissionsInternalAsync(RoleTemplate template, IEnumerable permissionCodes, CancellationToken cancellationToken) + { + // 确保模板已持久化,便于 FK 正确填充 + if (!dbContext.Entry(template).IsKeySet || template.Id == 0) + { + await dbContext.SaveChangesAsync(cancellationToken); + } + + var normalized = permissionCodes + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Select(code => code.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var existing = await dbContext.RoleTemplatePermissions + .Where(x => x.RoleTemplateId == template.Id) + .ToListAsync(cancellationToken); + + dbContext.RoleTemplatePermissions.RemoveRange(existing); + + var toAdd = normalized.Select(code => new RoleTemplatePermission + { + RoleTemplateId = template.Id, + PermissionCode = code + }); + + await dbContext.RoleTemplatePermissions.AddRangeAsync(toAdd, cancellationToken); + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs index a9246dd..01c8da2 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs @@ -1,5 +1,3 @@ -using System; -using System.Linq; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -12,6 +10,8 @@ using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser; using DomainPermission = TakeoutSaaS.Domain.Identity.Entities.Permission; using DomainRole = TakeoutSaaS.Domain.Identity.Entities.Role; using DomainRolePermission = TakeoutSaaS.Domain.Identity.Entities.RolePermission; +using DomainRoleTemplate = TakeoutSaaS.Domain.Identity.Entities.RoleTemplate; +using DomainRoleTemplatePermission = TakeoutSaaS.Domain.Identity.Entities.RoleTemplatePermission; using DomainUserRole = TakeoutSaaS.Domain.Identity.Entities.UserRole; namespace TakeoutSaaS.Infrastructure.Identity.Persistence; @@ -37,6 +37,8 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger return; } + await SeedRoleTemplatesAsync(context, options.RoleTemplates, cancellationToken); + foreach (var userOptions in options.Users) { using var tenantScope = EnterTenantScope(tenantContextAccessor, userOptions.TenantId); @@ -159,6 +161,66 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + private static async Task SeedRoleTemplatesAsync( + IdentityDbContext context, + IList templates, + CancellationToken cancellationToken) + { + if (templates is null || templates.Count == 0) + { + return; + } + + foreach (var templateOptions in templates) + { + if (string.IsNullOrWhiteSpace(templateOptions.TemplateCode) || string.IsNullOrWhiteSpace(templateOptions.Name)) + { + continue; + } + + var code = templateOptions.TemplateCode.Trim(); + var existing = await context.RoleTemplates.FirstOrDefaultAsync(x => x.TemplateCode == code, cancellationToken); + + if (existing == null) + { + existing = new DomainRoleTemplate + { + TemplateCode = code, + Name = templateOptions.Name.Trim(), + Description = templateOptions.Description, + IsActive = templateOptions.IsActive + }; + + await context.RoleTemplates.AddAsync(existing, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + else + { + existing.Name = templateOptions.Name.Trim(); + existing.Description = templateOptions.Description; + existing.IsActive = templateOptions.IsActive; + context.RoleTemplates.Update(existing); + await context.SaveChangesAsync(cancellationToken); + } + + var permissionCodes = NormalizeValues(templateOptions.Permissions); + var existingPermissions = await context.RoleTemplatePermissions + .Where(x => x.RoleTemplateId == existing.Id) + .ToListAsync(cancellationToken); + + context.RoleTemplatePermissions.RemoveRange(existingPermissions); + + var toAdd = permissionCodes.Select(code => new DomainRoleTemplatePermission + { + RoleTemplateId = existing.Id, + PermissionCode = code + }); + + await context.RoleTemplatePermissions.AddRangeAsync(toAdd, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + } + private static string[] NormalizeValues(string[]? values) => values == null ? [] diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs index dfe4d4f..268a7c0 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using TakeoutSaaS.Domain.Identity.Entities; @@ -34,6 +33,16 @@ public sealed class IdentityDbContext( /// public DbSet Roles => Set(); + /// + /// 角色模板集合(平台级)。 + /// + public DbSet RoleTemplates => Set(); + + /// + /// 角色模板权限集合。 + /// + public DbSet RoleTemplatePermissions => Set(); + /// /// 权限集合。 /// @@ -59,6 +68,8 @@ public sealed class IdentityDbContext( ConfigureIdentityUser(modelBuilder.Entity()); ConfigureMiniUser(modelBuilder.Entity()); ConfigureRole(modelBuilder.Entity()); + ConfigureRoleTemplate(modelBuilder.Entity()); + ConfigureRoleTemplatePermission(modelBuilder.Entity()); ConfigurePermission(modelBuilder.Entity()); ConfigureUserRole(modelBuilder.Entity()); ConfigureRolePermission(modelBuilder.Entity()); @@ -133,6 +144,28 @@ public sealed class IdentityDbContext( builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique(); } + private static void ConfigureRoleTemplate(EntityTypeBuilder builder) + { + builder.ToTable("role_templates"); + builder.HasKey(x => x.Id); + builder.Property(x => x.TemplateCode).HasMaxLength(64).IsRequired(); + builder.Property(x => x.Name).HasMaxLength(128).IsRequired(); + builder.Property(x => x.Description).HasMaxLength(256); + builder.Property(x => x.IsActive).IsRequired(); + ConfigureAuditableEntity(builder); + builder.HasIndex(x => x.TemplateCode).IsUnique(); + } + + private static void ConfigureRoleTemplatePermission(EntityTypeBuilder builder) + { + builder.ToTable("role_template_permissions"); + builder.HasKey(x => x.Id); + builder.Property(x => x.RoleTemplateId).IsRequired(); + builder.Property(x => x.PermissionCode).HasMaxLength(128).IsRequired(); + ConfigureAuditableEntity(builder); + builder.HasIndex(x => new { x.RoleTemplateId, x.PermissionCode }).IsUnique(); + } + private static void ConfigureUserRole(EntityTypeBuilder builder) { builder.ToTable("user_roles"); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs index 78d3f0e..71c3080 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs @@ -1,8 +1,8 @@ +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; -using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Infrastructure.Identity.Options; @@ -32,7 +32,7 @@ public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptio // 1. 构建 JWT Claims(包含用户 ID、账号、租户 ID、商户 ID、角色、权限等) var claims = BuildClaims(profile); - + // 2. 创建签名凭据(使用 HMAC SHA256 算法) var signingCredentials = new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)), @@ -49,7 +49,7 @@ public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptio // 4. 序列化 JWT 为字符串 var accessToken = _tokenHandler.WriteToken(jwt); - + // 5. 生成刷新令牌并存储到 Redis var refreshDescriptor = await refreshTokenStore.IssueAsync(profile.UserId, refreshExpires, cancellationToken); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs index e997c2e..3516290 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisLoginRateLimiter.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; using TakeoutSaaS.Application.Identity.Abstractions; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs index 36105e9..6c5651e 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/RedisRefreshTokenStore.cs @@ -1,10 +1,7 @@ -using System; -using System.Security.Cryptography; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text.Json; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Models; using TakeoutSaaS.Infrastructure.Identity.Options; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs index 1bd8b1b..bbc6328 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/WeChatAuthService.cs @@ -1,10 +1,6 @@ -using System; -using System.Net.Http; +using Microsoft.Extensions.Options; using System.Net.Http.Json; using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Options; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Infrastructure.Identity.Options; using TakeoutSaaS.Shared.Abstractions.Constants; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.cs index 4b31527..6e26a56 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/20251202005208_InitSnowflake_App.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.cs index 374be49..7a1aa27 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202005247_InitSnowflake_Dictionary.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.cs index 816fe03..27d688a 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251202043204_AddSystemParametersTable.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.cs index 772ad91..4893332 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202005226_InitSnowflake_Identity.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.cs index aa64905..42a404e 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251202084523_AddRbacModel.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs index b8df4fe..780e58d 100644 --- a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationHandler.cs @@ -1,6 +1,3 @@ -using System; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; namespace TakeoutSaaS.Module.Authorization.Policies; diff --git a/src/Modules/TakeoutSaaS.Module.Delivery/TakeoutSaaS.Module.Delivery.csproj b/src/Modules/TakeoutSaaS.Module.Delivery/TakeoutSaaS.Module.Delivery.csproj index b407eac..ff77596 100644 --- a/src/Modules/TakeoutSaaS.Module.Delivery/TakeoutSaaS.Module.Delivery.csproj +++ b/src/Modules/TakeoutSaaS.Module.Delivery/TakeoutSaaS.Module.Delivery.csproj @@ -4,6 +4,9 @@ enable enable + + + diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs index 666c554..456be39 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessagePublisher.cs @@ -1,6 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; - namespace TakeoutSaaS.Module.Messaging.Abstractions; /// diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs index 1e7e0bd..685c523 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Abstractions/IMessageSubscriber.cs @@ -1,7 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - namespace TakeoutSaaS.Module.Messaging.Abstractions; /// diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs index 701a992..3f2178c 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessagePublisher.cs @@ -1,5 +1,3 @@ -using System; -using System.Text; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using RabbitMQ.Client; @@ -22,17 +20,21 @@ public sealed class RabbitMqMessagePublisher(RabbitMqConnectionFactory connectio /// public Task PublishAsync(string routingKey, T message, CancellationToken cancellationToken = default) { + // 1. 确保通道可用 EnsureChannel(); var options = optionsMonitor.CurrentValue; var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available."); + // 2. 声明交换机 channel.ExchangeDeclare(options.Exchange, options.ExchangeType, durable: true, autoDelete: false); + // 3. 序列化消息并设置属性 var body = serializer.Serialize(message); var props = channel.CreateBasicProperties(); props.ContentType = "application/json"; props.DeliveryMode = 2; props.MessageId = Guid.NewGuid().ToString("N"); + // 4. 发布消息 channel.BasicPublish(options.Exchange, routingKey, props, body); logger.LogDebug("发布消息到交换机 {Exchange} RoutingKey {RoutingKey}", options.Exchange, routingKey); return Task.CompletedTask; diff --git a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs index 1acba98..ef50289 100644 --- a/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs +++ b/src/Modules/TakeoutSaaS.Module.Messaging/Services/RabbitMqMessageSubscriber.cs @@ -21,16 +21,19 @@ public sealed class RabbitMqMessageSubscriber(RabbitMqConnectionFactory connecti /// public async Task SubscribeAsync(string queue, string routingKey, Func> handler, CancellationToken cancellationToken = default) { + // 1. 确保通道可用 EnsureChannel(); var options = optionsMonitor.CurrentValue; var channel = _channel ?? throw new InvalidOperationException("RabbitMQ channel is not available."); + // 2. 声明交换机、队列及绑定 channel.ExchangeDeclare(options.Exchange, options.ExchangeType, durable: true, autoDelete: false); channel.QueueDeclare(queue, durable: true, exclusive: false, autoDelete: false); channel.QueueBind(queue, options.Exchange, routingKey); channel.BasicQos(0, options.PrefetchCount, global: false); + // 3. 设置消费者回调 var consumer = new AsyncEventingBasicConsumer(channel); consumer.Received += async (_, ea) => { @@ -61,6 +64,7 @@ public sealed class RabbitMqMessageSubscriber(RabbitMqConnectionFactory connecti } }; + // 4. 开始消费 channel.BasicConsume(queue, autoAck: false, consumer); await Task.CompletedTask.ConfigureAwait(false); } diff --git a/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs b/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs index 79f5a29..198da74 100644 --- a/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs +++ b/src/Modules/TakeoutSaaS.Module.Scheduler/Abstractions/IRecurringJobRegistrar.cs @@ -1,6 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; - namespace TakeoutSaaS.Module.Scheduler.Abstractions; /// diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs index 5cf7dd5..01380db 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Abstractions/ISmsSender.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Module.Sms.Models; namespace TakeoutSaaS.Module.Sms.Abstractions; diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs b/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs index 643f299..fe3b333 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Models/SmsSendRequest.cs @@ -1,5 +1,3 @@ -using System.Collections.Generic; - namespace TakeoutSaaS.Module.Sms.Models; /// diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs b/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs index 5520760..32c3ada 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Options/SmsOptions.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using TakeoutSaaS.Module.Sms; namespace TakeoutSaaS.Module.Sms.Options; diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs index c94a47c..213cfa0 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/AliyunSmsSender.cs @@ -1,4 +1,3 @@ -using System.Net.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Sms.Abstractions; @@ -13,8 +12,6 @@ namespace TakeoutSaaS.Module.Sms.Services; public sealed class AliyunSmsSender(IHttpClientFactory httpClientFactory, IOptionsMonitor optionsMonitor, ILogger logger) : ISmsSender { - private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; - /// public SmsProviderKind Provider => SmsProviderKind.Aliyun; @@ -27,6 +24,8 @@ public sealed class AliyunSmsSender(IHttpClientFactory httpClientFactory, IOptio logger.LogInformation("Mock 发送阿里云短信到 {Phone}, Template:{Template}", request.PhoneNumber, request.TemplateCode); return Task.FromResult(new SmsSendResult { Success = true, Message = "Mocked" }); } + // 预留 HttpClient,便于后续接入阿里云正式签名请求 + using var httpClient = httpClientFactory.CreateClient(nameof(AliyunSmsSender)); // 占位:保留待接入阿里云正式签名流程,当前返回未实现。 logger.LogWarning("阿里云短信尚未启用,请配置腾讯云或开启 UseMock。"); diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs index e47f6be..2d5906b 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/SmsSenderResolver.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Sms.Abstractions; using TakeoutSaaS.Module.Sms.Options; diff --git a/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs b/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs index f7b9737..ee14a8f 100644 --- a/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs +++ b/src/Modules/TakeoutSaaS.Module.Sms/Services/TencentSmsSender.cs @@ -1,10 +1,9 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using System.Globalization; -using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Text.Json; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Sms.Abstractions; using TakeoutSaaS.Module.Sms.Models; using TakeoutSaaS.Module.Sms.Options; @@ -27,6 +26,7 @@ public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOpti /// public async Task SendAsync(SmsSendRequest request, CancellationToken cancellationToken = default) { + // 1. 读取配置并处理 Mock var options = optionsMonitor.CurrentValue; if (options.UseMock) { @@ -34,6 +34,7 @@ public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOpti return new SmsSendResult { Success = true, Message = "Mocked" }; } + // 2. 构建请求负载与签名所需字段 var tencent = options.Tencent; var payload = BuildPayload(request, tencent); var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); @@ -44,6 +45,7 @@ public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOpti var stringToSign = BuildStringToSign(canonicalRequest, timestamp, date); var signature = Sign(stringToSign, tencent.SecretKey, date); + // 3. 构建 HTTP 请求 using var httpClient = httpClientFactory.CreateClient(nameof(TencentSmsSender)); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, tencent.Endpoint) { @@ -58,6 +60,7 @@ public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOpti httpRequest.Headers.Add("Authorization", $"TC3-HMAC-SHA256 Credential={tencent.SecretId}/{date}/{Service}/tc3_request, SignedHeaders=content-type;host, Signature={signature}"); + // 4. 发送请求并读取响应 var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); @@ -67,6 +70,7 @@ public sealed class TencentSmsSender(IHttpClientFactory httpClientFactory, IOpti return new SmsSendResult { Success = false, Message = content }; } + // 5. 解析响应 using var doc = JsonDocument.Parse(content); var root = doc.RootElement.GetProperty("Response"); var status = root.GetProperty("SendStatusSet")[0]; diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs index c53009c..41b6e31 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Abstractions/IObjectStorageProvider.cs @@ -1,5 +1,3 @@ -using System.Threading; -using System.Threading.Tasks; using TakeoutSaaS.Module.Storage.Models; namespace TakeoutSaaS.Module.Storage.Abstractions; diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs index 80d6a81..201e394 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadRequest.cs @@ -12,8 +12,6 @@ namespace TakeoutSaaS.Module.Storage.Models; /// 签名有效期。 public sealed class StorageDirectUploadRequest(string objectKey, string contentType, long contentLength, TimeSpan expires) { - - /// /// 目标对象键。 /// diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs index 8bfade5..15d11dd 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageDirectUploadResult.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace TakeoutSaaS.Module.Storage.Models; /// diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs index 3d055ab..795a614 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Models/StorageUploadRequest.cs @@ -1,6 +1,3 @@ -using System.Collections.Generic; -using System.IO; - namespace TakeoutSaaS.Module.Storage.Models; /// @@ -25,7 +22,6 @@ public sealed class StorageUploadRequest( TimeSpan signedUrlExpires, IDictionary? metadata = null) { - /// /// 对象键。 /// diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs index abb74ed..0244720 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/AliyunOssStorageProvider.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; using Aliyun.OSS; -using Aliyun.OSS.Util; using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Storage.Abstractions; using TakeoutSaaS.Module.Storage.Models; @@ -28,6 +22,7 @@ public sealed class AliyunOssStorageProvider(IOptionsMonitor opt /// public async Task UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default) { + // 1. 准备元数据 var options = CurrentOptions; var metadata = new ObjectMetadata { @@ -41,13 +36,16 @@ public sealed class AliyunOssStorageProvider(IOptionsMonitor opt } // Aliyun OSS SDK 支持异步方法,如未支持将同步封装为任务。 + // 2. 上传对象 await PutObjectAsync(options.AliyunOss.Bucket, request.ObjectKey, request.Content, metadata, cancellationToken) .ConfigureAwait(false); + // 3. 生成签名或公有 URL var signedUrl = request.GenerateSignedUrl ? await GenerateDownloadUrlAsync(request.ObjectKey, request.SignedUrlExpires, cancellationToken).ConfigureAwait(false) : null; + // 4. 返回上传结果 return new StorageUploadResult { ObjectKey = request.ObjectKey, @@ -61,10 +59,12 @@ public sealed class AliyunOssStorageProvider(IOptionsMonitor opt /// public Task CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default) { + // 1. 计算过期时间并生成直传/下载链接 var expiresAt = DateTimeOffset.UtcNow.Add(request.Expires); var uploadUrl = GeneratePresignedUrl(request.ObjectKey, request.Expires, SignHttpMethod.Put, request.ContentType); var downloadUrl = GeneratePresignedUrl(request.ObjectKey, request.Expires, SignHttpMethod.Get, null); + // 2. 返回直传参数 var result = new StorageDirectUploadResult { UploadUrl = uploadUrl, @@ -80,6 +80,7 @@ public sealed class AliyunOssStorageProvider(IOptionsMonitor opt /// public Task GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default) { + // 1. 生成预签名下载 URL var url = GeneratePresignedUrl(objectKey, expires, SignHttpMethod.Get, null); return Task.FromResult(url); } @@ -110,6 +111,7 @@ public sealed class AliyunOssStorageProvider(IOptionsMonitor opt private async Task PutObjectAsync(string bucket, string key, Stream content, ObjectMetadata metadata, CancellationToken cancellationToken) { var client = EnsureClient(); + // SDK 无异步则封装为 Task await Task.Run(() => client.PutObject(bucket, key, content, metadata), cancellationToken).ConfigureAwait(false); } diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs index df6b05d..bccb657 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/QiniuKodoStorageProvider.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Storage.Options; diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs index afdfe96..0745830 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/S3StorageProviderBase.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Amazon; using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; @@ -65,6 +60,7 @@ public abstract class S3StorageProviderBase : IObjectStorageProvider, IDisposabl /// public virtual async Task UploadAsync(StorageUploadRequest request, CancellationToken cancellationToken = default) { + // 1. 构建上传请求 var putRequest = new PutObjectRequest { BucketName = Bucket, @@ -79,12 +75,15 @@ public abstract class S3StorageProviderBase : IObjectStorageProvider, IDisposabl putRequest.Metadata[kv.Key] = kv.Value; } + // 2. 执行上传 await Client.PutObjectAsync(putRequest, cancellationToken).ConfigureAwait(false); + // 3. 根据需要生成签名 URL var signedUrl = request.GenerateSignedUrl ? GenerateSignedUrl(request.ObjectKey, request.SignedUrlExpires) : null; + // 4. 返回上传结果 return new StorageUploadResult { ObjectKey = request.ObjectKey, @@ -98,10 +97,12 @@ public abstract class S3StorageProviderBase : IObjectStorageProvider, IDisposabl /// public virtual Task CreateDirectUploadAsync(StorageDirectUploadRequest request, CancellationToken cancellationToken = default) { + // 1. 计算过期时间并生成直传 URL var expiresAt = DateTimeOffset.UtcNow.Add(request.Expires); var uploadUrl = GenerateSignedUrl(request.ObjectKey, request.Expires, HttpVerb.PUT, request.ContentType); var signedDownload = GenerateSignedUrl(request.ObjectKey, request.Expires); + // 2. 返回直传参数 var result = new StorageDirectUploadResult { UploadUrl = uploadUrl, @@ -117,6 +118,7 @@ public abstract class S3StorageProviderBase : IObjectStorageProvider, IDisposabl /// public virtual Task GenerateDownloadUrlAsync(string objectKey, TimeSpan expires, CancellationToken cancellationToken = default) { + // 1. 生成下载签名 URL var url = GenerateSignedUrl(objectKey, expires); return Task.FromResult(url); } diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs b/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs index fdd7795..259d6e4 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Providers/TencentCosStorageProvider.cs @@ -1,4 +1,3 @@ -using System; using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Storage.Options; diff --git a/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs b/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs index 598d1ee..b1920c0 100644 --- a/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs +++ b/src/Modules/TakeoutSaaS.Module.Storage/Services/StorageProviderResolver.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.Extensions.Options; using TakeoutSaaS.Module.Storage.Abstractions; using TakeoutSaaS.Module.Storage.Options; diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs index 2a7f7ab..b3b0589 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantContextAccessor.cs @@ -1,4 +1,3 @@ -using System.Threading; using TakeoutSaaS.Shared.Abstractions.Tenancy; namespace TakeoutSaaS.Module.Tenancy; diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs index 069202d..9f6b1b9 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs @@ -11,8 +11,6 @@ namespace TakeoutSaaS.Module.Tenancy; /// 租户上下文访问器 public sealed class TenantProvider(ITenantContextAccessor tenantContextAccessor) : ITenantProvider { - - /// public long GetCurrentTenantId() => tenantContextAccessor.Current?.TenantId ?? 0; diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs index 2a117bf..46c9770 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs @@ -1,4 +1,3 @@ -using System.Linq; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -20,8 +19,6 @@ public sealed class TenantResolutionMiddleware( ITenantContextAccessor tenantContextAccessor, IOptionsMonitor optionsMonitor) { - - /// /// 解析租户并将上下文注入请求。 /// diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs index 7cd928d..e43afa3 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionOptions.cs @@ -33,7 +33,7 @@ public sealed class TenantResolutionOptions /// 根域(不含子域),用于形如 {tenant}.rootDomain 的场景,例如 admin.takeoutsaas.com。 /// public string? RootDomain { get; set; } - + /// /// 需要跳过租户解析的路径集合(如健康检查),默认仅包含 /health。 /// diff --git a/stylecop.json b/stylecop.json new file mode 100644 index 0000000..c5f59ab --- /dev/null +++ b/stylecop.json @@ -0,0 +1,7 @@ +{ + "settings": { + "documentationRules": { + "documentationCulture": "zh-CN" + } + } +}