13
.editorconfig
Normal file
13
.editorconfig
Normal file
@@ -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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ bin/
|
||||
obj/
|
||||
**/bin/
|
||||
**/obj/
|
||||
.claude/
|
||||
|
||||
112
AGENTS.md
112
AGENTS.md
@@ -47,11 +47,21 @@
|
||||
|
||||
## 4. 注释与文档
|
||||
* **强制 XML 注释**:所有 `public` 的类、方法、属性必须有 `<summary>`。
|
||||
* **步骤注释**:超过 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` 类/方法/属性添加了 `<summary>`?
|
||||
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<Foo> logger) { }`。
|
||||
6. **record/required/init**:DTO 默认用 record;关键属性用 `required`;不可变属性用 `init`。
|
||||
7. **集合表达式与展开**:使用 `[]` 创建集合,`[..other]` 拼接,`str[1..^1]` 进行切片。
|
||||
8. **模式匹配**:列表模式 `[1, 2, .. var rest]`、属性模式 `{ IsActive: true }`、switch 表达式简化分支。
|
||||
9. **文件范围命名空间/全局 using**:减少缩进与重复引用;复杂泛型用别名。
|
||||
10. **顶级语句**:Program.cs 保持顶级语句风格。
|
||||
11. **原始/UTF-8 字面量**:多行文本用 `"""`,性能场景用 `"text"u8`。
|
||||
12. **不可变命令优先**:命令/DTO 优先用 record 和 `with` 非破坏性拷贝,例如 `command = command with { MerchantId = merchantId };`,避免直接 `command.Property = ...` 带来的副作用。
|
||||
## 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<char>` 处理字符串/切片,避免 Substring/复制。
|
||||
2. **栈分配与数组池**:小缓冲用 `stackalloc`,大缓冲统一用 `ArrayPool<T>.Shared`,禁止直接 `new` 大数组。
|
||||
3. **UTF-8 字面量**:常量字节使用 `"text"u8`,避免运行时编码。
|
||||
4. **避免装箱**:热点路径规避隐式装箱,必要时用 `ref struct` 约束栈分配。
|
||||
5. **Frozen 集合**:只读查找表用 `FrozenDictionary/FrozenSet`,初始化后不再修改。
|
||||
6. **SearchValues SIMD 查找**:Span 内多字符搜索用 `SearchValues.Create(...)` + `ContainsAny`。
|
||||
7. **预设集合容量**:`List/Dictionary` 预知规模必须指定 `Capacity`。
|
||||
8. **ValueTask 热点返回**:可能同步完成的异步返回 `ValueTask<T>`,减少 Task 分配。
|
||||
9. **Parallel.ForEachAsync 控并发**:I/O 并发用 Parallel.ForEachAsync 控制并行度,替代粗暴 Task.WhenAll。
|
||||
10. **避免 Task.Run**:在 ASP.NET Core 请求中不使用 Task.Run 做后台工作,改用 IHostedService 或 Channel 模式。
|
||||
11. **Channel<T> 代替锁**:多线程数据传递优先使用 Channels,实现无锁生产者-消费者。
|
||||
12. **NativeAOT/PGO/向量化**:微服务/工具开启 NativeAOT;保留动态 PGO;计算密集场景考虑 System.Runtime.Intrinsics。
|
||||
13. **System.Text.Json + 源生成器**:全面替换 Newtonsoft.Json;使用 `[JsonSerializable]` + 生成的 `JsonSerializerContext`,兼容 NativeAOT,零反射。
|
||||
14. **Pipelines 处理流**:TCP/文件流解析使用 `PipeReader/PipeWriter`,获得零拷贝与缓冲管理。
|
||||
15. **HybridCache**:内存+分布式缓存统一用 HybridCache,利用防击穿合并并发请求。
|
||||
## 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<char>`,严禁使用 `Substring`。
|
||||
* **数组分配**:小块内存 (<1KB) 使用 `stackalloc`;大块内存 (>1KB) 必须使用 `ArrayPool<T>.Shared`。
|
||||
2. **并发模型**:
|
||||
* **无锁编程**:进程内生产者-消费者模型**必须**使用 `System.Threading.Channels`,严禁使用 `lock` 或 `BlockingCollection`。
|
||||
* **并行控制**:I/O 密集型并发必须使用 `Parallel.ForEachAsync` 并配置 `MaxDegreeOfParallelism`。
|
||||
* **异步返回**:热点路径下,若可能同步完成,返回类型必须为 `ValueTask<T>`。
|
||||
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 或同等机制。
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
@@ -6,5 +6,7 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="$(MSBuildThisFileDirectory)stylecop.json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -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 脚本。
|
||||
- [ ] 版本与发布说明模板整理并在仓库中提供示例。
|
||||
|
||||
|
||||
@@ -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 监控与告警。
|
||||
- [ ] 监控与运维:上线发布流程、灰度/回滚策略、系统稳定性指标、24x7 监控与告警。
|
||||
|
||||
@@ -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 普通用户。
|
||||
@@ -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 步骤替换为实际变量名。
|
||||
- 所有密码、密钥务必放在“密钥/凭据”类型变量中,不要写入代码库。
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台认证接口
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
///
|
||||
/// </remarks>
|
||||
/// <param name="authService"></param>
|
||||
/// <remarks>提供登录、刷新 Token 以及用户权限查询能力。</remarks>
|
||||
/// <param name="authService">认证服务</param>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/auth")]
|
||||
public sealed class AuthController(IAdminAuthService authService) : BaseApiController
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 登录获取 Token
|
||||
/// </summary>
|
||||
/// <param name="request">登录请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>包含访问令牌与刷新令牌的响应。</returns>
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
@@ -43,6 +37,9 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr
|
||||
/// <summary>
|
||||
/// 刷新 Token
|
||||
/// </summary>
|
||||
/// <param name="request">刷新令牌请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>新的访问令牌与刷新令牌。</returns>
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
@@ -78,18 +75,22 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>当前用户档案信息。</returns>
|
||||
[HttpGet("profile")]
|
||||
[PermissionAuthorize("identity:profile:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<CurrentUserProfile>> GetProfile(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 从 JWT 中获取当前用户标识
|
||||
var userId = User.GetUserId();
|
||||
if (userId == 0)
|
||||
{
|
||||
return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
|
||||
}
|
||||
|
||||
// 2. 读取用户档案并返回
|
||||
var profile = await authService.GetProfileAsync(userId, cancellationToken);
|
||||
return ApiResponse<CurrentUserProfile>.Ok(profile);
|
||||
}
|
||||
@@ -119,6 +120,9 @@ public sealed class AuthController(IAdminAuthService authService) : BaseApiContr
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
/// <param name="userId">目标用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户权限概览,未找到则返回 404。</returns>
|
||||
[HttpGet("permissions/{userId:long}")]
|
||||
[PermissionAuthorize("identity:permission:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<UserPermissionDto>), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// 配送单管理。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化控制器。
|
||||
/// </remarks>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/deliveries")]
|
||||
public sealed class DeliveriesController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 创建配送单。
|
||||
/// </summary>
|
||||
/// <param name="command">创建命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>创建后的配送单。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("delivery:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DeliveryOrderDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DeliveryOrderDto>> Create([FromBody] CreateDeliveryOrderCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建配送单
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<DeliveryOrderDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询配送单列表。
|
||||
/// </summary>
|
||||
/// <param name="orderId">订单 ID。</param>
|
||||
/// <param name="status">配送状态。</param>
|
||||
/// <param name="page">页码。</param>
|
||||
/// <param name="pageSize">每页大小。</param>
|
||||
/// <param name="sortBy">排序字段。</param>
|
||||
/// <param name="sortDesc">是否倒序。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配送单分页列表。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("delivery:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<DeliveryOrderDto>>), 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<PagedResult<DeliveryOrderDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取配送单详情。
|
||||
/// </summary>
|
||||
/// <param name="deliveryOrderId">配送单 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>配送单详情或未找到。</returns>
|
||||
[HttpGet("{deliveryOrderId:long}")]
|
||||
[PermissionAuthorize("delivery:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DeliveryOrderDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<DeliveryOrderDto>> Detail(long deliveryOrderId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询配送单详情
|
||||
var result = await mediator.Send(new GetDeliveryOrderByIdQuery { DeliveryOrderId = deliveryOrderId }, cancellationToken);
|
||||
|
||||
// 2. 返回详情或 404
|
||||
return result == null
|
||||
? ApiResponse<DeliveryOrderDto>.Error(ErrorCodes.NotFound, "配送单不存在")
|
||||
: ApiResponse<DeliveryOrderDto>.Ok(result);
|
||||
@@ -84,17 +100,26 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 更新配送单。
|
||||
/// </summary>
|
||||
/// <param name="deliveryOrderId">配送单 ID。</param>
|
||||
/// <param name="command">更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的配送单或未找到。</returns>
|
||||
[HttpPut("{deliveryOrderId:long}")]
|
||||
[PermissionAuthorize("delivery:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DeliveryOrderDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<DeliveryOrderDto>> Update(long deliveryOrderId, [FromBody] UpdateDeliveryOrderCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 确保命令携带配送单标识
|
||||
if (command.DeliveryOrderId == 0)
|
||||
{
|
||||
command = command with { DeliveryOrderId = deliveryOrderId };
|
||||
}
|
||||
|
||||
// 2. 执行更新
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回更新结果或 404
|
||||
return result == null
|
||||
? ApiResponse<DeliveryOrderDto>.Error(ErrorCodes.NotFound, "配送单不存在")
|
||||
: ApiResponse<DeliveryOrderDto>.Ok(result);
|
||||
@@ -103,13 +128,19 @@ public sealed class DeliveriesController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 删除配送单。
|
||||
/// </summary>
|
||||
/// <param name="deliveryOrderId">配送单 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>删除结果,未找到则返回错误。</returns>
|
||||
[HttpDelete("{deliveryOrderId:long}")]
|
||||
[PermissionAuthorize("delivery:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long deliveryOrderId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行删除
|
||||
var success = await mediator.Send(new DeleteDeliveryOrderCommand { DeliveryOrderId = deliveryOrderId }, cancellationToken);
|
||||
|
||||
// 2. 返回结果或 404
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "配送单不存在");
|
||||
|
||||
@@ -12,74 +12,101 @@ namespace TakeoutSaaS.AdminApi.Controllers;
|
||||
/// <summary>
|
||||
/// 参数字典管理。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化字典控制器。
|
||||
/// </remarks>
|
||||
/// <param name="dictionaryAppService">字典服务</param>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/dictionaries")]
|
||||
public sealed class DictionaryController(IDictionaryAppService dictionaryAppService) : BaseApiController
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 查询字典分组。
|
||||
/// </summary>
|
||||
/// <param name="query">分组查询条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分组列表。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("dictionary:group:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<DictionaryGroupDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<DictionaryGroupDto>>> GetGroups([FromQuery] DictionaryGroupQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询字典分组
|
||||
var groups = await dictionaryAppService.SearchGroupsAsync(query, cancellationToken);
|
||||
|
||||
// 2. 返回分组列表
|
||||
return ApiResponse<IReadOnlyList<DictionaryGroupDto>>.Ok(groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建字典分组。
|
||||
/// </summary>
|
||||
/// <param name="request">创建分组请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>创建后的分组。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("dictionary:group:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryGroupDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryGroupDto>> CreateGroup([FromBody] CreateDictionaryGroupRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建字典分组
|
||||
var group = await dictionaryAppService.CreateGroupAsync(request, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<DictionaryGroupDto>.Ok(group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新字典分组。
|
||||
/// </summary>
|
||||
/// <param name="groupId">分组 ID。</param>
|
||||
/// <param name="request">更新请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的分组。</returns>
|
||||
[HttpPut("{groupId:long}")]
|
||||
[PermissionAuthorize("dictionary:group:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryGroupDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryGroupDto>> UpdateGroup(long groupId, [FromBody] UpdateDictionaryGroupRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 更新字典分组
|
||||
var group = await dictionaryAppService.UpdateGroupAsync(groupId, request, cancellationToken);
|
||||
|
||||
// 2. 返回更新结果
|
||||
return ApiResponse<DictionaryGroupDto>.Ok(group);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典分组。
|
||||
/// </summary>
|
||||
/// <param name="groupId">分组 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>操作结果。</returns>
|
||||
[HttpDelete("{groupId:long}")]
|
||||
[PermissionAuthorize("dictionary:group:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteGroup(long groupId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 删除字典分组
|
||||
await dictionaryAppService.DeleteGroupAsync(groupId, cancellationToken);
|
||||
|
||||
// 2. 返回成功响应
|
||||
return ApiResponse.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建字典项。
|
||||
/// </summary>
|
||||
/// <param name="groupId">分组 ID。</param>
|
||||
/// <param name="request">创建请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>创建的字典项。</returns>
|
||||
[HttpPost("{groupId:long}/items")]
|
||||
[PermissionAuthorize("dictionary:item:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryItemDto>> CreateItem(long groupId, [FromBody] CreateDictionaryItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 绑定分组标识
|
||||
request.GroupId = groupId;
|
||||
|
||||
// 2. 创建字典项
|
||||
var item = await dictionaryAppService.CreateItemAsync(request, cancellationToken);
|
||||
return ApiResponse<DictionaryItemDto>.Ok(item);
|
||||
}
|
||||
@@ -87,35 +114,54 @@ public sealed class DictionaryController(IDictionaryAppService dictionaryAppServ
|
||||
/// <summary>
|
||||
/// 更新字典项。
|
||||
/// </summary>
|
||||
/// <param name="itemId">字典项 ID。</param>
|
||||
/// <param name="request">更新请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的字典项。</returns>
|
||||
[HttpPut("items/{itemId:long}")]
|
||||
[PermissionAuthorize("dictionary:item:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<DictionaryItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<DictionaryItemDto>> UpdateItem(long itemId, [FromBody] UpdateDictionaryItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 更新字典项
|
||||
var item = await dictionaryAppService.UpdateItemAsync(itemId, request, cancellationToken);
|
||||
|
||||
// 2. 返回更新结果
|
||||
return ApiResponse<DictionaryItemDto>.Ok(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除字典项。
|
||||
/// </summary>
|
||||
/// <param name="itemId">字典项 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>操作结果。</returns>
|
||||
[HttpDelete("items/{itemId:long}")]
|
||||
[PermissionAuthorize("dictionary:item:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteItem(long itemId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 删除字典项
|
||||
await dictionaryAppService.DeleteItemAsync(itemId, cancellationToken);
|
||||
|
||||
// 2. 返回成功响应
|
||||
return ApiResponse.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取字典项(命中缓存)。
|
||||
/// </summary>
|
||||
/// <param name="request">批量查询请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分组编码到字典项列表的映射。</returns>
|
||||
[HttpPost("batch")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>> BatchGet([FromBody] DictionaryBatchQueryRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 批量读取并命中缓存
|
||||
var dictionaries = await dictionaryAppService.GetCachedItemsAsync(request, cancellationToken);
|
||||
|
||||
// 2. 返回批量结果
|
||||
return ApiResponse<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>>.Ok(dictionaries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 上传图片或文件。
|
||||
/// </summary>
|
||||
/// <returns>文件上传响应信息。</returns>
|
||||
[HttpPost("upload")]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ApiResponse<FileUploadResponse>> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验文件有效性
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "文件不能为空");
|
||||
}
|
||||
|
||||
// 2. 解析上传类型
|
||||
if (!UploadFileTypeParser.TryParse(type, out var uploadType))
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.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<FileUploadResponse>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<object>), StatusCodes.Status200OK)]
|
||||
public ApiResponse<object> Get()
|
||||
{
|
||||
// 1. 构造健康状态
|
||||
var payload = new { status = "OK", service = "AdminApi", time = DateTime.UtcNow };
|
||||
|
||||
// 2. 返回健康响应
|
||||
return ApiResponse<object>.Ok(payload);
|
||||
}
|
||||
}
|
||||
|
||||
149
src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs
Normal file
149
src/Api/TakeoutSaaS.AdminApi/Controllers/InventoryController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 库存管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/inventory")]
|
||||
public sealed class InventoryController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询库存。
|
||||
/// </summary>
|
||||
[HttpGet("{productSkuId:long}")]
|
||||
[PermissionAuthorize("inventory:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<InventoryItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<InventoryItemDto>> Get(long storeId, long productSkuId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetInventoryItemQuery { StoreId = storeId, ProductSkuId = productSkuId }, cancellationToken);
|
||||
return result is null
|
||||
? ApiResponse<InventoryItemDto>.Error(ErrorCodes.NotFound, "库存不存在")
|
||||
: ApiResponse<InventoryItemDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调整库存(入库/盘点/报损)。
|
||||
/// </summary>
|
||||
[HttpPost("adjust")]
|
||||
[PermissionAuthorize("inventory:adjust")]
|
||||
[ProducesResponseType(typeof(ApiResponse<InventoryItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<InventoryItemDto>> 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<InventoryItemDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 锁定库存(下单占用)。
|
||||
/// </summary>
|
||||
[HttpPost("lock")]
|
||||
[PermissionAuthorize("inventory:lock")]
|
||||
[ProducesResponseType(typeof(ApiResponse<InventoryItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<InventoryItemDto>> 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<InventoryItemDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放库存(取消订单等)。
|
||||
/// </summary>
|
||||
[HttpPost("release")]
|
||||
[PermissionAuthorize("inventory:release")]
|
||||
[ProducesResponseType(typeof(ApiResponse<InventoryItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<InventoryItemDto>> 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<InventoryItemDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扣减库存(支付或履约成功)。
|
||||
/// </summary>
|
||||
[HttpPost("deduct")]
|
||||
[PermissionAuthorize("inventory:deduct")]
|
||||
[ProducesResponseType(typeof(ApiResponse<InventoryItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<InventoryItemDto>> 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<InventoryItemDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询批次列表。
|
||||
/// </summary>
|
||||
[HttpGet("{productSkuId:long}/batches")]
|
||||
[PermissionAuthorize("inventory:batch:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<InventoryBatchDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<InventoryBatchDto>>> GetBatches(long storeId, long productSkuId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetInventoryBatchesQuery
|
||||
{
|
||||
StoreId = storeId,
|
||||
ProductSkuId = productSkuId
|
||||
}, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<InventoryBatchDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新批次。
|
||||
/// </summary>
|
||||
[HttpPost("{productSkuId:long}/batches")]
|
||||
[PermissionAuthorize("inventory:batch:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<InventoryBatchDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<InventoryBatchDto>> 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<InventoryBatchDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放过期锁定。
|
||||
/// </summary>
|
||||
[HttpPost("locks/expire")]
|
||||
[PermissionAuthorize("inventory:release")]
|
||||
[ProducesResponseType(typeof(ApiResponse<int>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<int>> ReleaseExpiredLocks(CancellationToken cancellationToken)
|
||||
{
|
||||
var count = await mediator.Send(new ReleaseExpiredInventoryLocksCommand(), cancellationToken);
|
||||
return ApiResponse<int>.Ok(count);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 商户类目管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/merchant-categories")]
|
||||
public sealed class MerchantCategoriesController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 列出所有类目。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>类目列表。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("merchant_category:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MerchantCategoryDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<MerchantCategoryDto>>> List(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询所有类目
|
||||
var result = await mediator.Send(new ListMerchantCategoriesQuery(), cancellationToken);
|
||||
|
||||
// 2. 返回类目列表
|
||||
return ApiResponse<IReadOnlyList<MerchantCategoryDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增类目。
|
||||
/// </summary>
|
||||
/// <param name="command">创建命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>创建的类目。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("merchant_category:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<MerchantCategoryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MerchantCategoryDto>> Create([FromBody] CreateMerchantCategoryCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建类目
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<MerchantCategoryDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除类目。
|
||||
/// </summary>
|
||||
/// <param name="categoryId">类目 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>删除结果,未找到则返回错误。</returns>
|
||||
[HttpDelete("{categoryId:long}")]
|
||||
[PermissionAuthorize("merchant_category:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long categoryId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行删除
|
||||
var success = await mediator.Send(new DeleteMerchantCategoryCommand(categoryId), cancellationToken);
|
||||
|
||||
// 2. 返回删除结果或 404
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "类目不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量调整类目排序。
|
||||
/// </summary>
|
||||
/// <param name="command">排序命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>执行结果。</returns>
|
||||
[HttpPost("reorder")]
|
||||
[PermissionAuthorize("merchant_category:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> Reorder([FromBody] ReorderMerchantCategoriesCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行排序调整
|
||||
await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回成功结果
|
||||
return ApiResponse<object>.Ok(null);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// 商户管理。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化控制器。
|
||||
/// </remarks>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/merchants")]
|
||||
public sealed class MerchantsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 创建商户。
|
||||
/// </summary>
|
||||
/// <param name="command">创建命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>创建后的商户。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("merchant:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<MerchantDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MerchantDto>> Create([FromBody] CreateMerchantCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建商户
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<MerchantDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询商户列表。
|
||||
/// </summary>
|
||||
/// <param name="status">状态筛选。</param>
|
||||
/// <param name="page">页码。</param>
|
||||
/// <param name="pageSize">每页大小。</param>
|
||||
/// <param name="sortBy">排序字段。</param>
|
||||
/// <param name="sortDesc">是否倒序。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>商户分页结果。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("merchant:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MerchantDto>>), 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<PagedResult<MerchantDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新商户。
|
||||
/// </summary>
|
||||
/// <param name="merchantId">商户 ID。</param>
|
||||
/// <param name="command">更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的商户或未找到。</returns>
|
||||
[HttpPut("{merchantId:long}")]
|
||||
[PermissionAuthorize("merchant:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<MerchantDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<MerchantDto>> Update(long merchantId, [FromBody] UpdateMerchantCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 绑定商户标识
|
||||
if (command.MerchantId == 0)
|
||||
{
|
||||
command = command with { MerchantId = merchantId };
|
||||
}
|
||||
|
||||
// 2. 执行更新
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回更新结果或 404
|
||||
return result == null
|
||||
? ApiResponse<MerchantDto>.Error(ErrorCodes.NotFound, "商户不存在")
|
||||
: ApiResponse<MerchantDto>.Ok(result);
|
||||
@@ -86,30 +104,225 @@ public sealed class MerchantsController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 删除商户。
|
||||
/// </summary>
|
||||
/// <param name="merchantId">商户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpDelete("{merchantId:long}")]
|
||||
[PermissionAuthorize("merchant:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long merchantId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行删除
|
||||
var success = await mediator.Send(new DeleteMerchantCommand { MerchantId = merchantId }, cancellationToken);
|
||||
|
||||
// 2. 返回删除结果或 404
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商户不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取商户详情。
|
||||
/// 获取商户概览。
|
||||
/// </summary>
|
||||
/// <param name="merchantId">商户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>商户概览或未找到。</returns>
|
||||
[HttpGet("{merchantId:long}")]
|
||||
[PermissionAuthorize("merchant:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<MerchantDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<MerchantDto>> Detail(long merchantId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询商户概览
|
||||
var result = await mediator.Send(new GetMerchantByIdQuery { MerchantId = merchantId }, cancellationToken);
|
||||
|
||||
// 2. 返回结果或 404
|
||||
return result == null
|
||||
? ApiResponse<MerchantDto>.Error(ErrorCodes.NotFound, "商户不存在")
|
||||
: ApiResponse<MerchantDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取商户详细资料(含证照、合同)。
|
||||
/// </summary>
|
||||
/// <returns>创建的证照信息。</returns>
|
||||
[HttpGet("{merchantId:long}/detail")]
|
||||
[PermissionAuthorize("merchant:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<MerchantDetailDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MerchantDetailDto>> FullDetail(long merchantId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询商户详细资料
|
||||
var result = await mediator.Send(new GetMerchantDetailQuery(merchantId), cancellationToken);
|
||||
|
||||
// 2. 返回详情
|
||||
return ApiResponse<MerchantDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上传商户证照信息(先通过文件上传接口获取 COS 地址)。
|
||||
/// </summary>
|
||||
/// <returns>创建的证照信息。</returns>
|
||||
[HttpPost("{merchantId:long}/documents")]
|
||||
[PermissionAuthorize("merchant:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<MerchantDocumentDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MerchantDocumentDto>> 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<MerchantDocumentDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 商户证照列表。
|
||||
/// </summary>
|
||||
/// <returns>商户证照列表。</returns>
|
||||
[HttpGet("{merchantId:long}/documents")]
|
||||
[PermissionAuthorize("merchant:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MerchantDocumentDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<MerchantDocumentDto>>> Documents(long merchantId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询证照列表
|
||||
var result = await mediator.Send(new GetMerchantDocumentsQuery(merchantId), cancellationToken);
|
||||
|
||||
// 2. 返回证照集合
|
||||
return ApiResponse<IReadOnlyList<MerchantDocumentDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 审核指定证照。
|
||||
/// </summary>
|
||||
/// <returns>审核后的证照信息。</returns>
|
||||
[HttpPost("{merchantId:long}/documents/{documentId:long}/review")]
|
||||
[PermissionAuthorize("merchant:review")]
|
||||
[ProducesResponseType(typeof(ApiResponse<MerchantDocumentDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MerchantDocumentDto>> 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<MerchantDocumentDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增商户合同。
|
||||
/// </summary>
|
||||
/// <returns>创建的合同信息。</returns>
|
||||
[HttpPost("{merchantId:long}/contracts")]
|
||||
[PermissionAuthorize("merchant:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<MerchantContractDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MerchantContractDto>> 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<MerchantContractDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 合同列表。
|
||||
/// </summary>
|
||||
/// <returns>商户合同列表。</returns>
|
||||
[HttpGet("{merchantId:long}/contracts")]
|
||||
[PermissionAuthorize("merchant:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MerchantContractDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<MerchantContractDto>>> Contracts(long merchantId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询合同列表
|
||||
var result = await mediator.Send(new GetMerchantContractsQuery(merchantId), cancellationToken);
|
||||
|
||||
// 2. 返回合同集合
|
||||
return ApiResponse<IReadOnlyList<MerchantContractDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新合同状态(生效/终止等)。
|
||||
/// </summary>
|
||||
/// <returns>更新后的合同信息。</returns>
|
||||
[HttpPut("{merchantId:long}/contracts/{contractId:long}/status")]
|
||||
[PermissionAuthorize("merchant:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<MerchantContractDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MerchantContractDto>> 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<MerchantContractDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 审核商户(通过/驳回)。
|
||||
/// </summary>
|
||||
/// <returns>审核后的商户信息。</returns>
|
||||
[HttpPost("{merchantId:long}/review")]
|
||||
[PermissionAuthorize("merchant:review")]
|
||||
[ProducesResponseType(typeof(ApiResponse<MerchantDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<MerchantDto>> 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<MerchantDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 审核日志。
|
||||
/// </summary>
|
||||
/// <returns>商户审核日志分页结果。</returns>
|
||||
[HttpGet("{merchantId:long}/audits")]
|
||||
[PermissionAuthorize("merchant:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<MerchantAuditLogDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<MerchantAuditLogDto>>> 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<PagedResult<MerchantAuditLogDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 可选商户类目列表。
|
||||
/// </summary>
|
||||
/// <returns>可选的商户类目列表。</returns>
|
||||
[HttpGet("categories")]
|
||||
[PermissionAuthorize("merchant:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<string>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<string>>> Categories(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询可选类目
|
||||
var result = await mediator.Send(new GetMerchantCategoriesQuery(), cancellationToken);
|
||||
|
||||
// 2. 返回类目列表
|
||||
return ApiResponse<IReadOnlyList<string>>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// 订单管理。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化控制器。
|
||||
/// </remarks>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/orders")]
|
||||
public sealed class OrdersController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 创建订单。
|
||||
/// </summary>
|
||||
/// <returns>创建的订单信息。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("order:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<OrderDto>> Create([FromBody] CreateOrderCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建订单
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<OrderDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询订单列表。
|
||||
/// </summary>
|
||||
/// <returns>订单分页列表。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("order:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<OrderDto>>), 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<PagedResult<OrderDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取订单详情。
|
||||
/// </summary>
|
||||
/// <returns>订单详情。</returns>
|
||||
[HttpGet("{orderId:long}")]
|
||||
[PermissionAuthorize("order:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<OrderDto>> Detail(long orderId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询订单详情
|
||||
var result = await mediator.Send(new GetOrderByIdQuery { OrderId = orderId }, cancellationToken);
|
||||
|
||||
// 2. 返回详情或 404
|
||||
return result == null
|
||||
? ApiResponse<OrderDto>.Error(ErrorCodes.NotFound, "订单不存在")
|
||||
: ApiResponse<OrderDto>.Ok(result);
|
||||
@@ -89,17 +94,23 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 更新订单。
|
||||
/// </summary>
|
||||
/// <returns>更新后的订单信息。</returns>
|
||||
[HttpPut("{orderId:long}")]
|
||||
[PermissionAuthorize("order:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<OrderDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<OrderDto>> Update(long orderId, [FromBody] UpdateOrderCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 确保命令包含订单标识
|
||||
if (command.OrderId == 0)
|
||||
{
|
||||
command = command with { OrderId = orderId };
|
||||
}
|
||||
|
||||
// 2. 执行更新
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回更新结果或 404
|
||||
return result == null
|
||||
? ApiResponse<OrderDto>.Error(ErrorCodes.NotFound, "订单不存在")
|
||||
: ApiResponse<OrderDto>.Ok(result);
|
||||
@@ -108,13 +119,17 @@ public sealed class OrdersController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 删除订单。
|
||||
/// </summary>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpDelete("{orderId:long}")]
|
||||
[PermissionAuthorize("order:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long orderId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行删除
|
||||
var success = await mediator.Send(new DeleteOrderCommand { OrderId = orderId }, cancellationToken);
|
||||
|
||||
// 2. 返回结果或 404
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "订单不存在");
|
||||
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// 支付记录管理。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化控制器。
|
||||
/// </remarks>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/payments")]
|
||||
public sealed class PaymentsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 创建支付记录。
|
||||
/// </summary>
|
||||
/// <returns>创建的支付记录信息。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("payment:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PaymentDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PaymentDto>> Create([FromBody] CreatePaymentCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建支付记录
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<PaymentDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询支付记录列表。
|
||||
/// </summary>
|
||||
/// <returns>支付记录分页列表。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("payment:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PaymentDto>>), 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<PagedResult<PaymentDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取支付记录详情。
|
||||
/// </summary>
|
||||
/// <returns>支付记录详情。</returns>
|
||||
[HttpGet("{paymentId:long}")]
|
||||
[PermissionAuthorize("payment:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<PaymentDto>> Detail(long paymentId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询支付记录详情
|
||||
var result = await mediator.Send(new GetPaymentByIdQuery { PaymentId = paymentId }, cancellationToken);
|
||||
|
||||
// 2. 返回详情或 404
|
||||
return result == null
|
||||
? ApiResponse<PaymentDto>.Error(ErrorCodes.NotFound, "支付记录不存在")
|
||||
: ApiResponse<PaymentDto>.Ok(result);
|
||||
@@ -84,17 +89,23 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 更新支付记录。
|
||||
/// </summary>
|
||||
/// <returns>更新后的支付记录信息。</returns>
|
||||
[HttpPut("{paymentId:long}")]
|
||||
[PermissionAuthorize("payment:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PaymentDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<PaymentDto>> Update(long paymentId, [FromBody] UpdatePaymentCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 确保命令包含支付记录标识
|
||||
if (command.PaymentId == 0)
|
||||
{
|
||||
command = command with { PaymentId = paymentId };
|
||||
}
|
||||
|
||||
// 2. 执行更新
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回更新结果或 404
|
||||
return result == null
|
||||
? ApiResponse<PaymentDto>.Error(ErrorCodes.NotFound, "支付记录不存在")
|
||||
: ApiResponse<PaymentDto>.Ok(result);
|
||||
@@ -103,13 +114,17 @@ public sealed class PaymentsController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 删除支付记录。
|
||||
/// </summary>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpDelete("{paymentId:long}")]
|
||||
[PermissionAuthorize("payment:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long paymentId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行删除
|
||||
var success = await mediator.Send(new DeletePaymentCommand { PaymentId = paymentId }, cancellationToken);
|
||||
|
||||
// 2. 返回结果或 404
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "支付记录不存在");
|
||||
|
||||
@@ -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
|
||||
/// <remarks>
|
||||
/// 示例:GET /api/admin/v1/permissions?keyword=order&page=1&pageSize=20
|
||||
/// </remarks>
|
||||
/// <param name="query">查询条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限的分页结果。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("identity:permission:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<PermissionDto>>), StatusCodes.Status200OK)]
|
||||
@@ -38,6 +40,9 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle
|
||||
/// <summary>
|
||||
/// 创建权限。
|
||||
/// </summary>
|
||||
/// <param name="command">创建命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>创建的权限。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("identity:permission:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PermissionDto>), StatusCodes.Status200OK)]
|
||||
@@ -50,6 +55,10 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle
|
||||
/// <summary>
|
||||
/// 更新权限。
|
||||
/// </summary>
|
||||
/// <param name="permissionId">权限 ID。</param>
|
||||
/// <param name="command">更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的权限,未找到时返回 404。</returns>
|
||||
[HttpPut("{permissionId:long}")]
|
||||
[PermissionAuthorize("identity:permission:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PermissionDto>), StatusCodes.Status200OK)]
|
||||
@@ -66,6 +75,9 @@ public sealed class PermissionsController(IMediator mediator) : BaseApiControlle
|
||||
/// <summary>
|
||||
/// 删除权限。
|
||||
/// </summary>
|
||||
/// <param name="permissionId">权限 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpDelete("{permissionId:long}")]
|
||||
[PermissionAuthorize("identity:permission:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// 商品管理。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化控制器。
|
||||
/// </remarks>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/products")]
|
||||
public sealed class ProductsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 创建商品。
|
||||
/// </summary>
|
||||
/// <returns>创建的商品信息。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("product:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ProductDto>> Create([FromBody] CreateProductCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建商品
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<ProductDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询商品列表。
|
||||
/// </summary>
|
||||
/// <returns>商品分页列表。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("product:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<ProductDto>>), 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<PagedResult<ProductDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取商品详情。
|
||||
/// </summary>
|
||||
/// <returns>商品详情。</returns>
|
||||
[HttpGet("{productId:long}")]
|
||||
[PermissionAuthorize("product:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<ProductDto>> Detail(long productId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询商品详情
|
||||
var result = await mediator.Send(new GetProductByIdQuery { ProductId = productId }, cancellationToken);
|
||||
|
||||
// 2. 返回详情或 404
|
||||
return result == null
|
||||
? ApiResponse<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在")
|
||||
: ApiResponse<ProductDto>.Ok(result);
|
||||
@@ -86,17 +91,23 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 更新商品。
|
||||
/// </summary>
|
||||
/// <returns>更新后的商品信息。</returns>
|
||||
[HttpPut("{productId:long}")]
|
||||
[PermissionAuthorize("product:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<ProductDto>> Update(long productId, [FromBody] UpdateProductCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 确保命令包含商品标识
|
||||
if (command.ProductId == 0)
|
||||
{
|
||||
command = command with { ProductId = productId };
|
||||
}
|
||||
|
||||
// 2. 执行更新
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回更新结果或 404
|
||||
return result == null
|
||||
? ApiResponse<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在")
|
||||
: ApiResponse<ProductDto>.Ok(result);
|
||||
@@ -105,15 +116,159 @@ public sealed class ProductsController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 删除商品。
|
||||
/// </summary>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpDelete("{productId:long}")]
|
||||
[PermissionAuthorize("product:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long productId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行删除
|
||||
var success = await mediator.Send(new DeleteProductCommand { ProductId = productId }, cancellationToken);
|
||||
|
||||
// 2. 返回结果或 404
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "商品不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取商品全量详情。
|
||||
/// </summary>
|
||||
[HttpGet("{productId:long}/detail")]
|
||||
[PermissionAuthorize("product:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<ProductDetailDto>> FullDetail(long productId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetProductDetailQuery { ProductId = productId }, cancellationToken);
|
||||
return result == null
|
||||
? ApiResponse<ProductDetailDto>.Error(ErrorCodes.NotFound, "商品不存在")
|
||||
: ApiResponse<ProductDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上架商品。
|
||||
/// </summary>
|
||||
[HttpPost("{productId:long}/publish")]
|
||||
[PermissionAuthorize("product:publish")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<ProductDto>> 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<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在")
|
||||
: ApiResponse<ProductDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 下架商品。
|
||||
/// </summary>
|
||||
[HttpPost("{productId:long}/unpublish")]
|
||||
[PermissionAuthorize("product:publish")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<ProductDto>> 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<ProductDto>.Error(ErrorCodes.NotFound, "商品不存在")
|
||||
: ApiResponse<ProductDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换商品 SKU。
|
||||
/// </summary>
|
||||
[HttpPut("{productId:long}/skus")]
|
||||
[PermissionAuthorize("product-sku:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductSkuDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductSkuDto>>> 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<IReadOnlyList<ProductSkuDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换商品规格。
|
||||
/// </summary>
|
||||
[HttpPut("{productId:long}/attributes")]
|
||||
[PermissionAuthorize("product-attr:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductAttributeGroupDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductAttributeGroupDto>>> 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<IReadOnlyList<ProductAttributeGroupDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换商品加料。
|
||||
/// </summary>
|
||||
[HttpPut("{productId:long}/addons")]
|
||||
[PermissionAuthorize("product-addon:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductAddonGroupDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductAddonGroupDto>>> 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<IReadOnlyList<ProductAddonGroupDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换商品媒资。
|
||||
/// </summary>
|
||||
[HttpPut("{productId:long}/media")]
|
||||
[PermissionAuthorize("product-media:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductMediaAssetDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductMediaAssetDto>>> 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<IReadOnlyList<ProductMediaAssetDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换商品价格策略。
|
||||
/// </summary>
|
||||
[HttpPut("{productId:long}/pricing-rules")]
|
||||
[PermissionAuthorize("product-pricing:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<ProductPricingRuleDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<ProductPricingRuleDto>>> 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<IReadOnlyList<ProductPricingRuleDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取预置角色模板列表。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 示例:GET /api/admin/v1/roles/templates
|
||||
/// </remarks>
|
||||
/// <returns>角色模板列表。</returns>
|
||||
[HttpGet("templates")]
|
||||
[PermissionAuthorize("identity:role:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RoleTemplateDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<RoleTemplateDto>>> ListTemplates([FromQuery] bool? isActive, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询模板列表
|
||||
var result = await mediator.Send(new ListRoleTemplatesQuery { IsActive = isActive }, cancellationToken);
|
||||
|
||||
// 2. 返回模板集合
|
||||
return ApiResponse<IReadOnlyList<RoleTemplateDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取单个角色模板详情。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 示例:GET /api/admin/v1/roles/templates/tenant-admin
|
||||
/// </remarks>
|
||||
/// <returns>角色模板详情。</returns>
|
||||
[HttpGet("templates/{templateCode}")]
|
||||
[PermissionAuthorize("identity:role:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<RoleTemplateDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<RoleTemplateDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<RoleTemplateDto>> GetTemplate(string templateCode, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询指定模板
|
||||
var result = await mediator.Send(new GetRoleTemplateQuery { TemplateCode = templateCode }, cancellationToken);
|
||||
|
||||
// 2. 返回模板或 404
|
||||
return result is null
|
||||
? ApiResponse<RoleTemplateDto>.Error(StatusCodes.Status404NotFound, "角色模板不存在")
|
||||
: ApiResponse<RoleTemplateDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建角色模板。
|
||||
/// </summary>
|
||||
/// <returns>创建的角色模板信息。</returns>
|
||||
[HttpPost("templates")]
|
||||
[PermissionAuthorize("role-template:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<RoleTemplateDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<RoleTemplateDto>> CreateTemplate([FromBody, Required] CreateRoleTemplateCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建模板
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<RoleTemplateDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新角色模板。
|
||||
/// </summary>
|
||||
/// <returns>更新后的角色模板信息。</returns>
|
||||
[HttpPut("templates/{templateCode}")]
|
||||
[PermissionAuthorize("role-template:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<RoleTemplateDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<RoleTemplateDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<RoleTemplateDto>> 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<RoleTemplateDto>.Error(StatusCodes.Status404NotFound, "角色模板不存在")
|
||||
: ApiResponse<RoleTemplateDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除角色模板。
|
||||
/// </summary>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpDelete("templates/{templateCode}")]
|
||||
[PermissionAuthorize("role-template:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<bool>> DeleteTemplate(string templateCode, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 删除模板
|
||||
var result = await mediator.Send(new DeleteRoleTemplateCommand { TemplateCode = templateCode }, cancellationToken);
|
||||
|
||||
// 2. 返回执行结果
|
||||
return ApiResponse<bool>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按模板复制角色并绑定权限。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 示例:POST /api/admin/v1/roles/templates/store-manager/copy
|
||||
/// Body: { "roleName": "新区店长" }
|
||||
/// </remarks>
|
||||
/// <returns>创建的角色信息。</returns>
|
||||
[HttpPost("templates/{templateCode}/copy")]
|
||||
[PermissionAuthorize("identity:role:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<RoleDto>> 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<RoleDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 为当前租户批量初始化预置角色模板。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 示例:POST /api/admin/v1/roles/templates/init
|
||||
/// Body: { "templateCodes": ["tenant-admin","store-manager","store-staff"] }
|
||||
/// </remarks>
|
||||
/// <returns>创建的角色列表。</returns>
|
||||
[HttpPost("templates/init")]
|
||||
[PermissionAuthorize("identity:role:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RoleDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<RoleDto>>> InitializeTemplates(
|
||||
[FromBody] InitializeRoleTemplatesCommand? command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 确保命令实例存在
|
||||
command ??= new InitializeRoleTemplatesCommand();
|
||||
|
||||
// 2. 执行初始化
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<RoleDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询角色。
|
||||
/// </summary>
|
||||
@@ -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
|
||||
/// </remarks>
|
||||
/// <returns>角色分页结果。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("identity:role:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<RoleDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<RoleDto>>> Search([FromQuery] SearchRolesQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询角色分页
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
// 2. 返回分页数据
|
||||
return ApiResponse<PagedResult<RoleDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建角色。
|
||||
/// </summary>
|
||||
/// <returns>创建的角色信息。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("identity:role:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<RoleDto>> Create([FromBody, Required] CreateRoleCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建角色
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<RoleDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新角色。
|
||||
/// </summary>
|
||||
/// <returns>更新后的角色信息。</returns>
|
||||
[HttpPut("{roleId:long}")]
|
||||
[PermissionAuthorize("identity:role:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<RoleDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<RoleDto>> Update(long roleId, [FromBody, Required] UpdateRoleCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 绑定角色标识
|
||||
command = command with { RoleId = roleId };
|
||||
|
||||
// 2. 执行更新
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回更新结果或 404
|
||||
return result is null
|
||||
? ApiResponse<RoleDto>.Error(StatusCodes.Status404NotFound, "角色不存在")
|
||||
: ApiResponse<RoleDto>.Ok(result);
|
||||
@@ -68,12 +226,16 @@ public sealed class RolesController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 删除角色。
|
||||
/// </summary>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpDelete("{roleId:long}")]
|
||||
[PermissionAuthorize("identity:role:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<bool>> Delete(long roleId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 构建删除命令
|
||||
var command = new DeleteRoleCommand { RoleId = roleId };
|
||||
|
||||
// 2. 执行删除
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<bool>.Ok(result);
|
||||
}
|
||||
@@ -81,12 +243,16 @@ public sealed class RolesController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 绑定角色权限(覆盖式)。
|
||||
/// </summary>
|
||||
/// <returns>是否绑定成功。</returns>
|
||||
[HttpPut("{roleId:long}/permissions")]
|
||||
[PermissionAuthorize("identity:role:bind-permission")]
|
||||
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<bool>> BindPermissions(long roleId, [FromBody, Required] BindRolePermissionsCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 绑定角色标识
|
||||
command = command with { RoleId = roleId };
|
||||
|
||||
// 2. 执行覆盖式授权
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<bool>.Ok(result);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店自提管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/pickup")]
|
||||
public sealed class StorePickupController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取自提配置。
|
||||
/// </summary>
|
||||
[HttpGet("settings")]
|
||||
[PermissionAuthorize("pickup-setting:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StorePickupSettingDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StorePickupSettingDto>> GetSetting(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetStorePickupSettingQuery { StoreId = storeId }, cancellationToken);
|
||||
return result is null
|
||||
? ApiResponse<StorePickupSettingDto>.Error(ErrorCodes.NotFound, "未配置自提设置")
|
||||
: ApiResponse<StorePickupSettingDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新自提配置。
|
||||
/// </summary>
|
||||
[HttpPut("settings")]
|
||||
[PermissionAuthorize("pickup-setting:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StorePickupSettingDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StorePickupSettingDto>> 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<StorePickupSettingDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询档期列表。
|
||||
/// </summary>
|
||||
[HttpGet("slots")]
|
||||
[PermissionAuthorize("pickup-slot:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StorePickupSlotDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<StorePickupSlotDto>>> ListSlots(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new ListStorePickupSlotsQuery { StoreId = storeId }, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<StorePickupSlotDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建档期。
|
||||
/// </summary>
|
||||
[HttpPost("slots")]
|
||||
[PermissionAuthorize("pickup-slot:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StorePickupSlotDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StorePickupSlotDto>> 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<StorePickupSlotDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新档期。
|
||||
/// </summary>
|
||||
[HttpPut("slots/{slotId:long}")]
|
||||
[PermissionAuthorize("pickup-slot:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StorePickupSlotDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<StorePickupSlotDto>> 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<StorePickupSlotDto>.Error(ErrorCodes.NotFound, "档期不存在")
|
||||
: ApiResponse<StorePickupSlotDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除档期。
|
||||
/// </summary>
|
||||
[HttpDelete("slots/{slotId:long}")]
|
||||
[PermissionAuthorize("pickup-slot:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<object>> DeleteSlot(long storeId, long slotId, CancellationToken cancellationToken)
|
||||
{
|
||||
var success = await mediator.Send(new DeleteStorePickupSlotCommand { StoreId = storeId, SlotId = slotId }, cancellationToken);
|
||||
return success ? ApiResponse<object>.Ok(null) : ApiResponse<object>.Error(ErrorCodes.NotFound, "档期不存在");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店排班管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/shifts")]
|
||||
public sealed class StoreShiftsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询排班(默认未来 7 天)。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("store-shift:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StoreEmployeeShiftDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<StoreEmployeeShiftDto>>> 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<IReadOnlyList<StoreEmployeeShiftDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建排班。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("store-shift:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreEmployeeShiftDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreEmployeeShiftDto>> 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<StoreEmployeeShiftDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新排班。
|
||||
/// </summary>
|
||||
[HttpPut("{shiftId:long}")]
|
||||
[PermissionAuthorize("store-shift:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreEmployeeShiftDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<StoreEmployeeShiftDto>> 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<StoreEmployeeShiftDto>.Error(ErrorCodes.NotFound, "排班不存在")
|
||||
: ApiResponse<StoreEmployeeShiftDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除排班。
|
||||
/// </summary>
|
||||
[HttpDelete("{shiftId:long}")]
|
||||
[PermissionAuthorize("store-shift:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long storeId, long shiftId, CancellationToken cancellationToken)
|
||||
{
|
||||
var success = await mediator.Send(new DeleteStoreEmployeeShiftCommand { StoreId = storeId, ShiftId = shiftId }, cancellationToken);
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "排班不存在");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店员工管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/staffs")]
|
||||
public sealed class StoreStaffsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询门店员工列表。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("store-staff:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StoreStaffDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<StoreStaffDto>>> 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<IReadOnlyList<StoreStaffDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建门店员工。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("store-staff:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreStaffDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreStaffDto>> 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<StoreStaffDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新门店员工。
|
||||
/// </summary>
|
||||
[HttpPut("{staffId:long}")]
|
||||
[PermissionAuthorize("store-staff:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreStaffDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<StoreStaffDto>> 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<StoreStaffDto>.Error(ErrorCodes.NotFound, "员工不存在")
|
||||
: ApiResponse<StoreStaffDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除门店员工。
|
||||
/// </summary>
|
||||
[HttpDelete("{staffId:long}")]
|
||||
[PermissionAuthorize("store-staff:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long storeId, long staffId, CancellationToken cancellationToken)
|
||||
{
|
||||
var success = await mediator.Send(new DeleteStoreStaffCommand { StoreId = storeId, StaffId = staffId }, cancellationToken);
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "员工不存在");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店桌台区域管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/table-areas")]
|
||||
public sealed class StoreTableAreasController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询区域列表。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("store-table-area:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StoreTableAreaDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<StoreTableAreaDto>>> List(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new ListStoreTableAreasQuery { StoreId = storeId }, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<StoreTableAreaDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建区域。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("store-table-area:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreTableAreaDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreTableAreaDto>> 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<StoreTableAreaDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新区域。
|
||||
/// </summary>
|
||||
[HttpPut("{areaId:long}")]
|
||||
[PermissionAuthorize("store-table-area:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreTableAreaDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<StoreTableAreaDto>> 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<StoreTableAreaDto>.Error(ErrorCodes.NotFound, "桌台区域不存在")
|
||||
: ApiResponse<StoreTableAreaDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除区域。
|
||||
/// </summary>
|
||||
[HttpDelete("{areaId:long}")]
|
||||
[PermissionAuthorize("store-table-area:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long storeId, long areaId, CancellationToken cancellationToken)
|
||||
{
|
||||
var success = await mediator.Send(new DeleteStoreTableAreaCommand { StoreId = storeId, AreaId = areaId }, cancellationToken);
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "桌台区域不存在或不可删除");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 门店桌码管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/stores/{storeId:long}/tables")]
|
||||
public sealed class StoreTablesController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询桌码列表。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("store-table:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StoreTableDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<StoreTableDto>>> 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<IReadOnlyList<StoreTableDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量生成桌码。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("store-table:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StoreTableDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<StoreTableDto>>> 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<IReadOnlyList<StoreTableDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新桌码。
|
||||
/// </summary>
|
||||
[HttpPut("{tableId:long}")]
|
||||
[PermissionAuthorize("store-table:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreTableDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<StoreTableDto>> 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<StoreTableDto>.Error(ErrorCodes.NotFound, "桌码不存在")
|
||||
: ApiResponse<StoreTableDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除桌码。
|
||||
/// </summary>
|
||||
[HttpDelete("{tableId:long}")]
|
||||
[PermissionAuthorize("store-table:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long storeId, long tableId, CancellationToken cancellationToken)
|
||||
{
|
||||
var success = await mediator.Send(new DeleteStoreTableCommand { StoreId = storeId, TableId = tableId }, cancellationToken);
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "桌码不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导出桌码二维码 ZIP。
|
||||
/// </summary>
|
||||
[HttpPost("export")]
|
||||
[PermissionAuthorize("store-table:export")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> 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<object>.Error(ErrorCodes.NotFound, "未找到可导出的桌码"));
|
||||
}
|
||||
|
||||
return File(result.Content, result.ContentType, result.FileName);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// 门店管理。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 初始化控制器。
|
||||
/// </remarks>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/stores")]
|
||||
public sealed class StoresController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 创建门店。
|
||||
/// </summary>
|
||||
/// <returns>创建的门店信息。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("store:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreDto>> Create([FromBody] CreateStoreCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建门店
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<StoreDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询门店列表。
|
||||
/// </summary>
|
||||
/// <returns>门店分页列表。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("store:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<StoreDto>>), 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<PagedResult<StoreDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取门店详情。
|
||||
/// </summary>
|
||||
/// <returns>门店详情。</returns>
|
||||
[HttpGet("{storeId:long}")]
|
||||
[PermissionAuthorize("store:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<StoreDto>> Detail(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询门店详情
|
||||
var result = await mediator.Send(new GetStoreByIdQuery { StoreId = storeId }, cancellationToken);
|
||||
|
||||
// 2. 返回详情或 404
|
||||
return result == null
|
||||
? ApiResponse<StoreDto>.Error(ErrorCodes.NotFound, "门店不存在")
|
||||
: ApiResponse<StoreDto>.Ok(result);
|
||||
@@ -84,17 +90,23 @@ public sealed class StoresController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 更新门店。
|
||||
/// </summary>
|
||||
/// <returns>更新后的门店信息。</returns>
|
||||
[HttpPut("{storeId:long}")]
|
||||
[PermissionAuthorize("store:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<StoreDto>> Update(long storeId, [FromBody] UpdateStoreCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 确保命令包含门店标识
|
||||
if (command.StoreId == 0)
|
||||
{
|
||||
command = command with { StoreId = storeId };
|
||||
}
|
||||
|
||||
// 2. 执行更新
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回更新结果或 404
|
||||
return result == null
|
||||
? ApiResponse<StoreDto>.Error(ErrorCodes.NotFound, "门店不存在")
|
||||
: ApiResponse<StoreDto>.Ok(result);
|
||||
@@ -103,15 +115,211 @@ public sealed class StoresController(IMediator mediator) : BaseApiController
|
||||
/// <summary>
|
||||
/// 删除门店。
|
||||
/// </summary>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpDelete("{storeId:long}")]
|
||||
[PermissionAuthorize("store:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行删除
|
||||
var success = await mediator.Send(new DeleteStoreCommand { StoreId = storeId }, cancellationToken);
|
||||
|
||||
// 2. 返回结果或 404
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "门店不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询门店营业时段。
|
||||
/// </summary>
|
||||
[HttpGet("{storeId:long}/business-hours")]
|
||||
[PermissionAuthorize("store:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StoreBusinessHourDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<StoreBusinessHourDto>>> ListBusinessHours(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new ListStoreBusinessHoursQuery { StoreId = storeId }, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<StoreBusinessHourDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增营业时段。
|
||||
/// </summary>
|
||||
[HttpPost("{storeId:long}/business-hours")]
|
||||
[PermissionAuthorize("store:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreBusinessHourDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreBusinessHourDto>> 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<StoreBusinessHourDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新营业时段。
|
||||
/// </summary>
|
||||
[HttpPut("{storeId:long}/business-hours/{businessHourId:long}")]
|
||||
[PermissionAuthorize("store:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreBusinessHourDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<StoreBusinessHourDto>> 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<StoreBusinessHourDto>.Error(ErrorCodes.NotFound, "营业时段不存在")
|
||||
: ApiResponse<StoreBusinessHourDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除营业时段。
|
||||
/// </summary>
|
||||
[HttpDelete("{storeId:long}/business-hours/{businessHourId:long}")]
|
||||
[PermissionAuthorize("store:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> DeleteBusinessHour(long storeId, long businessHourId, CancellationToken cancellationToken)
|
||||
{
|
||||
var success = await mediator.Send(new DeleteStoreBusinessHourCommand { StoreId = storeId, BusinessHourId = businessHourId }, cancellationToken);
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "营业时段不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询配送区域。
|
||||
/// </summary>
|
||||
[HttpGet("{storeId:long}/delivery-zones")]
|
||||
[PermissionAuthorize("store:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StoreDeliveryZoneDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<StoreDeliveryZoneDto>>> ListDeliveryZones(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new ListStoreDeliveryZonesQuery { StoreId = storeId }, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<StoreDeliveryZoneDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增配送区域。
|
||||
/// </summary>
|
||||
[HttpPost("{storeId:long}/delivery-zones")]
|
||||
[PermissionAuthorize("store:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDeliveryZoneDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreDeliveryZoneDto>> 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<StoreDeliveryZoneDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新配送区域。
|
||||
/// </summary>
|
||||
[HttpPut("{storeId:long}/delivery-zones/{deliveryZoneId:long}")]
|
||||
[PermissionAuthorize("store:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreDeliveryZoneDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<StoreDeliveryZoneDto>> 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<StoreDeliveryZoneDto>.Error(ErrorCodes.NotFound, "配送区域不存在")
|
||||
: ApiResponse<StoreDeliveryZoneDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除配送区域。
|
||||
/// </summary>
|
||||
[HttpDelete("{storeId:long}/delivery-zones/{deliveryZoneId:long}")]
|
||||
[PermissionAuthorize("store:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> DeleteDeliveryZone(long storeId, long deliveryZoneId, CancellationToken cancellationToken)
|
||||
{
|
||||
var success = await mediator.Send(new DeleteStoreDeliveryZoneCommand { StoreId = storeId, DeliveryZoneId = deliveryZoneId }, cancellationToken);
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "配送区域不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询门店节假日。
|
||||
/// </summary>
|
||||
[HttpGet("{storeId:long}/holidays")]
|
||||
[PermissionAuthorize("store:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StoreHolidayDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<StoreHolidayDto>>> ListHolidays(long storeId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new ListStoreHolidaysQuery { StoreId = storeId }, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<StoreHolidayDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增节假日配置。
|
||||
/// </summary>
|
||||
[HttpPost("{storeId:long}/holidays")]
|
||||
[PermissionAuthorize("store:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreHolidayDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreHolidayDto>> 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<StoreHolidayDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新节假日配置。
|
||||
/// </summary>
|
||||
[HttpPut("{storeId:long}/holidays/{holidayId:long}")]
|
||||
[PermissionAuthorize("store:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreHolidayDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<StoreHolidayDto>> 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<StoreHolidayDto>.Error(ErrorCodes.NotFound, "节假日配置不存在")
|
||||
: ApiResponse<StoreHolidayDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除节假日配置。
|
||||
/// </summary>
|
||||
[HttpDelete("{storeId:long}/holidays/{holidayId:long}")]
|
||||
[PermissionAuthorize("store:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> DeleteHoliday(long storeId, long holidayId, CancellationToken cancellationToken)
|
||||
{
|
||||
var success = await mediator.Send(new DeleteStoreHolidayCommand { StoreId = storeId, HolidayId = holidayId }, cancellationToken);
|
||||
return success
|
||||
? ApiResponse<object>.Ok(null)
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "节假日配置不存在");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// <summary>
|
||||
/// 创建系统参数。
|
||||
/// </summary>
|
||||
/// <returns>创建的系统参数信息。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("system-parameter:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SystemParameterDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<SystemParameterDto>> Create([FromBody] CreateSystemParameterCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建系统参数
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<SystemParameterDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询系统参数列表。
|
||||
/// </summary>
|
||||
/// <returns>分页的系统参数列表。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("system-parameter:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<SystemParameterDto>>), 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<PagedResult<SystemParameterDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取系统参数详情。
|
||||
/// </summary>
|
||||
/// <returns>系统参数详情。</returns>
|
||||
[HttpGet("{parameterId:long}")]
|
||||
[PermissionAuthorize("system-parameter:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SystemParameterDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<SystemParameterDto>> Detail(long parameterId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询参数详情
|
||||
var result = await mediator.Send(new GetSystemParameterByIdQuery(parameterId), cancellationToken);
|
||||
|
||||
// 2. 返回详情或 404
|
||||
return result == null
|
||||
? ApiResponse<SystemParameterDto>.Error(ErrorCodes.NotFound, "系统参数不存在")
|
||||
: ApiResponse<SystemParameterDto>.Ok(result);
|
||||
@@ -81,18 +91,23 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont
|
||||
/// <summary>
|
||||
/// 更新系统参数。
|
||||
/// </summary>
|
||||
/// <returns>更新后的系统参数信息。</returns>
|
||||
[HttpPut("{parameterId:long}")]
|
||||
[PermissionAuthorize("system-parameter:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<SystemParameterDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<SystemParameterDto>> Update(long parameterId, [FromBody] UpdateSystemParameterCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 确保命令包含参数标识
|
||||
if (command.ParameterId == 0)
|
||||
{
|
||||
command = command with { ParameterId = parameterId };
|
||||
}
|
||||
|
||||
// 2. 执行更新
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 3. 返回结果或 404
|
||||
return result == null
|
||||
? ApiResponse<SystemParameterDto>.Error(ErrorCodes.NotFound, "系统参数不存在")
|
||||
: ApiResponse<SystemParameterDto>.Ok(result);
|
||||
@@ -101,13 +116,17 @@ public sealed class SystemParametersController(IMediator mediator) : BaseApiCont
|
||||
/// <summary>
|
||||
/// 删除系统参数。
|
||||
/// </summary>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpDelete("{parameterId:long}")]
|
||||
[PermissionAuthorize("system-parameter:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long parameterId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行删除
|
||||
var success = await mediator.Send(new DeleteSystemParameterCommand { ParameterId = parameterId }, cancellationToken);
|
||||
|
||||
// 2. 返回成功或 404
|
||||
return success
|
||||
? ApiResponse.Success()
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "系统参数不存在");
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 租户公告管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/announcements")]
|
||||
public sealed class TenantAnnouncementsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询公告。
|
||||
/// </summary>
|
||||
/// <returns>租户公告分页结果。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("tenant-announcement:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantAnnouncementDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<TenantAnnouncementDto>>> 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<PagedResult<TenantAnnouncementDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 公告详情。
|
||||
/// </summary>
|
||||
/// <returns>租户公告详情。</returns>
|
||||
[HttpGet("{announcementId:long}")]
|
||||
[PermissionAuthorize("tenant-announcement:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<TenantAnnouncementDto>> 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<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建公告。
|
||||
/// </summary>
|
||||
/// <returns>创建的公告信息。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("tenant-announcement:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TenantAnnouncementDto>> 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<TenantAnnouncementDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新公告。
|
||||
/// </summary>
|
||||
/// <returns>更新后的公告信息。</returns>
|
||||
[HttpPut("{announcementId:long}")]
|
||||
[PermissionAuthorize("tenant-announcement:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<TenantAnnouncementDto>> 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<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除公告。
|
||||
/// </summary>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpDelete("{announcementId:long}")]
|
||||
[PermissionAuthorize("tenant-announcement:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<bool>> Delete(long tenantId, long announcementId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 删除公告
|
||||
var result = await mediator.Send(new DeleteTenantAnnouncementCommand { TenantId = tenantId, AnnouncementId = announcementId }, cancellationToken);
|
||||
|
||||
// 2. 返回执行结果
|
||||
return ApiResponse<bool>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记公告已读。
|
||||
/// </summary>
|
||||
/// <returns>标记已读后的公告信息。</returns>
|
||||
[HttpPost("{announcementId:long}/read")]
|
||||
[PermissionAuthorize("tenant-announcement:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantAnnouncementDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<TenantAnnouncementDto>> 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<TenantAnnouncementDto>.Error(StatusCodes.Status404NotFound, "公告不存在")
|
||||
: ApiResponse<TenantAnnouncementDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 租户账单管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/billings")]
|
||||
public sealed class TenantBillingsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询账单。
|
||||
/// </summary>
|
||||
/// <returns>租户账单分页结果。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("tenant-bill:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantBillingDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<TenantBillingDto>>> 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<PagedResult<TenantBillingDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 账单详情。
|
||||
/// </summary>
|
||||
/// <returns>租户账单详情。</returns>
|
||||
[HttpGet("{billingId:long}")]
|
||||
[PermissionAuthorize("tenant-bill:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantBillingDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantBillingDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<TenantBillingDto>> 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<TenantBillingDto>.Error(StatusCodes.Status404NotFound, "账单不存在")
|
||||
: ApiResponse<TenantBillingDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建账单。
|
||||
/// </summary>
|
||||
/// <returns>创建的账单信息。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("tenant-bill:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantBillingDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TenantBillingDto>> 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<TenantBillingDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记账单已支付。
|
||||
/// </summary>
|
||||
/// <returns>标记支付后的账单信息。</returns>
|
||||
[HttpPost("{billingId:long}/pay")]
|
||||
[PermissionAuthorize("tenant-bill:pay")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantBillingDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantBillingDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<TenantBillingDto>> 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<TenantBillingDto>.Error(StatusCodes.Status404NotFound, "账单不存在")
|
||||
: ApiResponse<TenantBillingDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 租户通知接口。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/tenants/{tenantId:long}/notifications")]
|
||||
public sealed class TenantNotificationsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询通知。
|
||||
/// </summary>
|
||||
/// <returns>租户通知分页结果。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("tenant-notification:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantNotificationDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<TenantNotificationDto>>> 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<PagedResult<TenantNotificationDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记通知已读。
|
||||
/// </summary>
|
||||
/// <returns>标记已读后的通知信息。</returns>
|
||||
[HttpPost("{notificationId:long}/read")]
|
||||
[PermissionAuthorize("tenant-notification:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantNotificationDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantNotificationDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<TenantNotificationDto>> 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<TenantNotificationDto>.Error(StatusCodes.Status404NotFound, "通知不存在")
|
||||
: ApiResponse<TenantNotificationDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 租户套餐管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/tenant-packages")]
|
||||
public sealed class TenantPackagesController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询租户套餐。
|
||||
/// </summary>
|
||||
/// <param name="query">查询条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>租户套餐分页结果。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("tenant-package:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantPackageDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<TenantPackageDto>>> Search([FromQuery] SearchTenantPackagesQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询租户套餐分页
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
// 2. 返回结果
|
||||
return ApiResponse<PagedResult<TenantPackageDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查看套餐详情。
|
||||
/// </summary>
|
||||
/// <param name="tenantPackageId">套餐 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>套餐详情或未找到。</returns>
|
||||
[HttpGet("{tenantPackageId:long}")]
|
||||
[PermissionAuthorize("tenant-package:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<TenantPackageDto>> Detail(long tenantPackageId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询套餐详情
|
||||
var result = await mediator.Send(new GetTenantPackageByIdQuery { TenantPackageId = tenantPackageId }, cancellationToken);
|
||||
|
||||
// 2. 返回查询结果或 404
|
||||
return result is null
|
||||
? ApiResponse<TenantPackageDto>.Error(StatusCodes.Status404NotFound, "套餐不存在")
|
||||
: ApiResponse<TenantPackageDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建套餐。
|
||||
/// </summary>
|
||||
/// <param name="command">创建命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>创建后的套餐。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("tenant-package:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TenantPackageDto>> Create([FromBody, Required] CreateTenantPackageCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行创建
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<TenantPackageDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新套餐。
|
||||
/// </summary>
|
||||
/// <param name="tenantPackageId">套餐 ID。</param>
|
||||
/// <param name="command">更新命令。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>更新后的套餐或未找到。</returns>
|
||||
[HttpPut("{tenantPackageId:long}")]
|
||||
[PermissionAuthorize("tenant-package:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantPackageDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<TenantPackageDto>> 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<TenantPackageDto>.Error(StatusCodes.Status404NotFound, "套餐不存在")
|
||||
: ApiResponse<TenantPackageDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除套餐。
|
||||
/// </summary>
|
||||
/// <param name="tenantPackageId">套餐 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>删除结果。</returns>
|
||||
[HttpDelete("{tenantPackageId:long}")]
|
||||
[PermissionAuthorize("tenant-package:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<bool>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<bool>> Delete(long tenantPackageId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 构建删除命令
|
||||
var command = new DeleteTenantPackageCommand { TenantPackageId = tenantPackageId };
|
||||
|
||||
// 2. 执行删除并返回
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
return ApiResponse<bool>.Ok(result);
|
||||
}
|
||||
}
|
||||
195
src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs
Normal file
195
src/Api/TakeoutSaaS.AdminApi/Controllers/TenantsController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 租户管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/tenants")]
|
||||
public sealed class TenantsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册租户并初始化套餐。
|
||||
/// </summary>
|
||||
/// <returns>注册的租户信息。</returns>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("tenant:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TenantDto>> Register([FromBody] RegisterTenantCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 注册租户并初始化套餐
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回注册结果
|
||||
return ApiResponse<TenantDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询租户。
|
||||
/// </summary>
|
||||
/// <returns>租户分页结果。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("tenant:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<TenantDto>>> Search([FromQuery] SearchTenantsQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询租户分页
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
// 2. 返回分页数据
|
||||
return ApiResponse<PagedResult<TenantDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查看租户详情。
|
||||
/// </summary>
|
||||
/// <returns>租户详情。</returns>
|
||||
[HttpGet("{tenantId:long}")]
|
||||
[PermissionAuthorize("tenant:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantDetailDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TenantDetailDto>> Detail(long tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询租户详情
|
||||
var result = await mediator.Send(new GetTenantByIdQuery(tenantId), cancellationToken);
|
||||
|
||||
// 2. 返回租户信息
|
||||
return ApiResponse<TenantDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交或更新实名认证资料。
|
||||
/// </summary>
|
||||
/// <returns>提交的实名认证信息。</returns>
|
||||
[HttpPost("{tenantId:long}/verification")]
|
||||
[PermissionAuthorize("tenant:review")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantVerificationDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TenantVerificationDto>> 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<TenantVerificationDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 审核租户。
|
||||
/// </summary>
|
||||
/// <returns>审核后的租户信息。</returns>
|
||||
[HttpPost("{tenantId:long}/review")]
|
||||
[PermissionAuthorize("tenant:review")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TenantDto>> 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<TenantDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建或续费租户订阅。
|
||||
/// </summary>
|
||||
/// <returns>创建或续费的订阅信息。</returns>
|
||||
[HttpPost("{tenantId:long}/subscriptions")]
|
||||
[PermissionAuthorize("tenant:subscription")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantSubscriptionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TenantSubscriptionDto>> 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<TenantSubscriptionDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 套餐升降配。
|
||||
/// </summary>
|
||||
/// <returns>更新后的订阅信息。</returns>
|
||||
[HttpPut("{tenantId:long}/subscriptions/{subscriptionId:long}/plan")]
|
||||
[PermissionAuthorize("tenant:subscription")]
|
||||
[ProducesResponseType(typeof(ApiResponse<TenantSubscriptionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TenantSubscriptionDto>> 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<TenantSubscriptionDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询审核日志。
|
||||
/// </summary>
|
||||
/// <returns>租户审核日志分页结果。</returns>
|
||||
[HttpGet("{tenantId:long}/audits")]
|
||||
[PermissionAuthorize("tenant:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<TenantAuditLogDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<TenantAuditLogDto>>> 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<PagedResult<TenantAuditLogDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配额校验并占用额度(门店/账号/短信/配送)。
|
||||
/// </summary>
|
||||
/// <remarks>需在请求头携带 X-Tenant-Id 对应的租户。</remarks>
|
||||
/// <returns>配额校验结果。</returns>
|
||||
[HttpPost("{tenantId:long}/quotas/check")]
|
||||
[PermissionAuthorize("tenant:quota:check")]
|
||||
[ProducesResponseType(typeof(ApiResponse<QuotaCheckResultDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<QuotaCheckResultDto>> 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<QuotaCheckResultDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// }
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
/// <param name="query">搜索条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页的用户权限概览。</returns>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("identity:permission:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<UserPermissionDto>>), 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<PagedResult<UserPermissionDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>("Endpoint");
|
||||
var useConsoleExporter = otelSection.GetValue<bool?>("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<string[]>();
|
||||
@@ -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)
|
||||
|
||||
@@ -173,4 +173,4 @@
|
||||
"Sampling": "ParentBasedAlwaysOn",
|
||||
"UseConsoleExporter": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,4 +173,4 @@
|
||||
"Sampling": "ParentBasedAlwaysOn",
|
||||
"UseConsoleExporter": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,38 +10,46 @@ namespace TakeoutSaaS.MiniApi.Controllers;
|
||||
/// <summary>
|
||||
/// 小程序登录认证
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 小程序登录认证
|
||||
/// </remarks>
|
||||
/// <param name="authService"></param>
|
||||
/// <remarks>提供小程序端的微信登录与 Token 刷新能力。</remarks>
|
||||
/// <param name="authService">小程序认证服务</param>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/auth")]
|
||||
public sealed class AuthController(IMiniAuthService authService) : BaseApiController
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 微信登录
|
||||
/// </summary>
|
||||
/// <param name="request">微信登录请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>包含访问令牌与刷新令牌的响应。</returns>
|
||||
[HttpPost("wechat/login")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TokenResponse>> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 调用认证服务完成微信登录
|
||||
var response = await authService.LoginWithWeChatAsync(request, cancellationToken);
|
||||
|
||||
// 2. 返回访问与刷新令牌
|
||||
return ApiResponse<TokenResponse>.Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新 Token
|
||||
/// </summary>
|
||||
/// <param name="request">刷新令牌请求。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>新的访问令牌与刷新令牌。</returns>
|
||||
[HttpPost("refresh")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<TokenResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 调用认证服务刷新 Token
|
||||
var response = await authService.RefreshTokenAsync(request, cancellationToken);
|
||||
|
||||
// 2. 返回新的令牌
|
||||
return ApiResponse<TokenResponse>.Ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 上传图片或文件。
|
||||
/// </summary>
|
||||
/// <param name="file">上传文件。</param>
|
||||
/// <param name="type">上传类型。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>上传结果,包含访问链接等信息。</returns>
|
||||
[HttpPost("upload")]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 30 * 1024 * 1024)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<FileUploadResponse>), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ApiResponse<FileUploadResponse>> Upload([FromForm] IFormFile? file, [FromForm] string? type, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验文件有效性
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.Error(ErrorCodes.BadRequest, "文件不能为空");
|
||||
}
|
||||
|
||||
// 2. 解析上传类型
|
||||
if (!UploadFileTypeParser.TryParse(type, out var uploadType))
|
||||
{
|
||||
return ApiResponse<FileUploadResponse>.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<FileUploadResponse>.Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<object>), StatusCodes.Status200OK)]
|
||||
public ApiResponse<object> Get()
|
||||
{
|
||||
// 1. 构造健康状态
|
||||
var payload = new { status = "OK", service = "MiniApi", time = DateTime.UtcNow };
|
||||
|
||||
// 2. 返回健康响应
|
||||
return ApiResponse<object>.Ok(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// 当前用户信息
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
///
|
||||
/// </remarks>
|
||||
/// <param name="authService"></param>
|
||||
/// <remarks>提供小程序端当前用户档案查询。</remarks>
|
||||
/// <param name="authService">小程序认证服务</param>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/me")]
|
||||
public sealed class MeController(IMiniAuthService authService) : BaseApiController
|
||||
{
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户档案
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>当前用户档案信息。</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status401Unauthorized)]
|
||||
public async Task<ApiResponse<CurrentUserProfile>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 从 JWT 中解析用户标识
|
||||
var userId = User.GetUserId();
|
||||
if (userId == 0)
|
||||
{
|
||||
return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
|
||||
}
|
||||
|
||||
// 2. 查询用户档案并返回
|
||||
var profile = await authService.GetProfileAsync(userId, cancellationToken);
|
||||
return ApiResponse<CurrentUserProfile>.Ok(profile);
|
||||
}
|
||||
|
||||
38
src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs
Normal file
38
src/Api/TakeoutSaaS.MiniApi/Controllers/MenusController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序端菜单查询。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/stores/{storeId:long}/menu")]
|
||||
public sealed class MenusController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取门店菜单(含分类与商品详情)。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreMenuDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<StoreMenuDto>> 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<StoreMenuDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 小程序端自提档期查询。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/stores/{storeId:long}/pickup-slots")]
|
||||
public sealed class PickupSlotsController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定日期可用档期。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<StorePickupSlotDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<StorePickupSlotDto>>> GetSlots(long storeId, [FromQuery] DateTime date, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetAvailablePickupSlotsQuery { StoreId = storeId, Date = date }, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<StorePickupSlotDto>>.Ok(result);
|
||||
}
|
||||
}
|
||||
35
src/Api/TakeoutSaaS.MiniApi/Controllers/TablesController.cs
Normal file
35
src/Api/TakeoutSaaS.MiniApi/Controllers/TablesController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 桌码上下文。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/mini/v{version:apiVersion}/tables")]
|
||||
public sealed class TablesController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 解析桌码并返回上下文。
|
||||
/// </summary>
|
||||
[HttpGet("{code}/context")]
|
||||
[ProducesResponseType(typeof(ApiResponse<StoreTableContextDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<StoreTableContextDto>> GetContext(string code, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetStoreTableContextQuery { TableCode = code }, cancellationToken);
|
||||
return result is null
|
||||
? ApiResponse<StoreTableContextDto>.Error(ErrorCodes.NotFound, "桌码不存在")
|
||||
: ApiResponse<StoreTableContextDto>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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<IIdGenerator>(_ => 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<string>("Endpoint");
|
||||
var useConsoleExporter = otelSection.GetValue<bool?>("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<string[]>();
|
||||
@@ -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)
|
||||
|
||||
@@ -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<object>), StatusCodes.Status200OK)]
|
||||
public ApiResponse<object> Get()
|
||||
{
|
||||
// 1. 构造健康状态
|
||||
var payload = new { status = "OK", service = "UserApi", time = DateTime.UtcNow };
|
||||
|
||||
// 2. 返回健康响应
|
||||
return ApiResponse<object>.Ok(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IIdGenerator>(_ => 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<string>("Endpoint");
|
||||
var useConsoleExporter = otelSection.GetValue<bool?>("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<string[]>();
|
||||
@@ -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)
|
||||
|
||||
@@ -13,12 +13,10 @@ namespace TakeoutSaaS.Application.App.Deliveries.Handlers;
|
||||
public sealed class CreateDeliveryOrderCommandHandler(IDeliveryRepository deliveryRepository, ILogger<CreateDeliveryOrderCommandHandler> logger)
|
||||
: IRequestHandler<CreateDeliveryOrderCommand, DeliveryOrderDto>
|
||||
{
|
||||
private readonly IDeliveryRepository _deliveryRepository = deliveryRepository;
|
||||
private readonly ILogger<CreateDeliveryOrderCommandHandler> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeliveryOrderDto> Handle(CreateDeliveryOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 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, []);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,23 +15,23 @@ public sealed class DeleteDeliveryOrderCommandHandler(
|
||||
ILogger<DeleteDeliveryOrderCommandHandler> logger)
|
||||
: IRequestHandler<DeleteDeliveryOrderCommand, bool>
|
||||
{
|
||||
private readonly IDeliveryRepository _deliveryRepository = deliveryRepository;
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider;
|
||||
private readonly ILogger<DeleteDeliveryOrderCommandHandler> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(DeleteDeliveryOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var existing = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken);
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -15,20 +15,23 @@ public sealed class GetDeliveryOrderByIdQueryHandler(
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetDeliveryOrderByIdQuery, DeliveryOrderDto?>
|
||||
{
|
||||
private readonly IDeliveryRepository _deliveryRepository = deliveryRepository;
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeliveryOrderDto?> Handle(GetDeliveryOrderByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var order = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken);
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,21 +15,25 @@ public sealed class SearchDeliveryOrdersQueryHandler(
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<SearchDeliveryOrdersQuery, PagedResult<DeliveryOrderDto>>
|
||||
{
|
||||
private readonly IDeliveryRepository _deliveryRepository = deliveryRepository;
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<DeliveryOrderDto>> Handle(SearchDeliveryOrdersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var orders = await _deliveryRepository.SearchAsync(tenantId, request.Status, request.OrderId, cancellationToken);
|
||||
// 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<DeliveryOrderDto>(items, request.Page, request.PageSize, orders.Count);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,20 +17,20 @@ public sealed class UpdateDeliveryOrderCommandHandler(
|
||||
ILogger<UpdateDeliveryOrderCommandHandler> logger)
|
||||
: IRequestHandler<UpdateDeliveryOrderCommand, DeliveryOrderDto?>
|
||||
{
|
||||
private readonly IDeliveryRepository _deliveryRepository = deliveryRepository;
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider;
|
||||
private readonly ILogger<UpdateDeliveryOrderCommandHandler> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeliveryOrderDto?> Handle(UpdateDeliveryOrderCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var existing = await _deliveryRepository.FindByIdAsync(request.DeliveryOrderId, tenantId, cancellationToken);
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 库存调整命令。
|
||||
/// </summary>
|
||||
public sealed record AdjustInventoryCommand : IRequest<InventoryItemDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 调整数量,正数入库,负数出库。
|
||||
/// </summary>
|
||||
public int QuantityDelta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 调整类型。
|
||||
/// </summary>
|
||||
public InventoryAdjustmentType AdjustmentType { get; init; } = InventoryAdjustmentType.Manual;
|
||||
|
||||
/// <summary>
|
||||
/// 原因说明。
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 安全库存阈值(可选)。
|
||||
/// </summary>
|
||||
public int? SafetyStock { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否售罄标记。
|
||||
/// </summary>
|
||||
public bool? IsSoldOut { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 扣减库存命令(履约/支付成功)。
|
||||
/// </summary>
|
||||
public sealed record DeductInventoryCommand : IRequest<InventoryItemDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 扣减数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否预售锁定转扣减。
|
||||
/// </summary>
|
||||
public bool IsPresaleOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 幂等键(与锁定请求一致可避免重复扣减)。
|
||||
/// </summary>
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 锁定库存命令。
|
||||
/// </summary>
|
||||
public sealed record LockInventoryCommand : IRequest<InventoryItemDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 锁定数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否按预售逻辑锁定。
|
||||
/// </summary>
|
||||
public bool IsPresaleOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 锁定过期时间(UTC),超时可释放。
|
||||
/// </summary>
|
||||
public DateTime? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 幂等键(同一键重复调用返回同一结果)。
|
||||
/// </summary>
|
||||
public string IdempotencyKey { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 释放过期库存锁定命令。
|
||||
/// </summary>
|
||||
public sealed record ReleaseExpiredInventoryLocksCommand : IRequest<int>;
|
||||
@@ -0,0 +1,35 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 释放库存命令。
|
||||
/// </summary>
|
||||
public sealed record ReleaseInventoryCommand : IRequest<InventoryItemDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 释放数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否预售锁定释放。
|
||||
/// </summary>
|
||||
public bool IsPresaleOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 幂等键(与锁定请求一致可避免重复释放)。
|
||||
/// </summary>
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新库存批次命令。
|
||||
/// </summary>
|
||||
public sealed record UpsertInventoryBatchCommand : IRequest<InventoryBatchDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 批次号。
|
||||
/// </summary>
|
||||
public string BatchNumber { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 生产日期。
|
||||
/// </summary>
|
||||
public DateTime? ProductionDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期日期。
|
||||
/// </summary>
|
||||
public DateTime? ExpireDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 入库数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余数量。
|
||||
/// </summary>
|
||||
public int RemainingQuantity { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 库存批次 DTO。
|
||||
/// </summary>
|
||||
public sealed record InventoryBatchDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 批次 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 批次号。
|
||||
/// </summary>
|
||||
public string BatchNumber { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 生产日期。
|
||||
/// </summary>
|
||||
public DateTime? ProductionDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期日期。
|
||||
/// </summary>
|
||||
public DateTime? ExpireDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 入库数量。
|
||||
/// </summary>
|
||||
public int Quantity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 剩余数量。
|
||||
/// </summary>
|
||||
public int RemainingQuantity { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 库存项 DTO。
|
||||
/// </summary>
|
||||
public sealed record InventoryItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 库存记录 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long ProductSkuId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 批次号。
|
||||
/// </summary>
|
||||
public string? BatchNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 可用库存。
|
||||
/// </summary>
|
||||
public int QuantityOnHand { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已锁定库存。
|
||||
/// </summary>
|
||||
public int QuantityReserved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 安全库存。
|
||||
/// </summary>
|
||||
public int? SafetyStock { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 储位。
|
||||
/// </summary>
|
||||
public string? Location { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 过期日期。
|
||||
/// </summary>
|
||||
public DateTime? ExpireDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否预售。
|
||||
/// </summary>
|
||||
public bool IsPresale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 预售开始时间。
|
||||
/// </summary>
|
||||
public DateTime? PresaleStartTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 预售结束时间。
|
||||
/// </summary>
|
||||
public DateTime? PresaleEndTime { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 预售上限。
|
||||
/// </summary>
|
||||
public int? PresaleCapacity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 已锁定预售量。
|
||||
/// </summary>
|
||||
public int PresaleLocked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 限购数量。
|
||||
/// </summary>
|
||||
public int? MaxQuantityPerOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否售罄。
|
||||
/// </summary>
|
||||
public bool IsSoldOut { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 库存调整处理器。
|
||||
/// </summary>
|
||||
public sealed class AdjustInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<AdjustInventoryCommandHandler> logger)
|
||||
: IRequestHandler<AdjustInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryItemDto> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 库存扣减处理器。
|
||||
/// </summary>
|
||||
public sealed class DeductInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<DeductInventoryCommandHandler> logger)
|
||||
: IRequestHandler<DeductInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryItemDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 库存批次查询处理器。
|
||||
/// </summary>
|
||||
public sealed class GetInventoryBatchesQueryHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetInventoryBatchesQuery, IReadOnlyList<InventoryBatchDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<InventoryBatchDto>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 查询库存处理器。
|
||||
/// </summary>
|
||||
public sealed class GetInventoryItemQueryHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetInventoryItemQuery, InventoryItemDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryItemDto?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 库存锁定处理器。
|
||||
/// </summary>
|
||||
public sealed class LockInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<LockInventoryCommandHandler> logger)
|
||||
: IRequestHandler<LockInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryItemDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 释放过期锁定处理器。
|
||||
/// </summary>
|
||||
public sealed class ReleaseExpiredInventoryLocksCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<ReleaseExpiredInventoryLocksCommandHandler> logger)
|
||||
: IRequestHandler<ReleaseExpiredInventoryLocksCommand, int>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<int> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 库存释放处理器。
|
||||
/// </summary>
|
||||
public sealed class ReleaseInventoryCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<ReleaseInventoryCommandHandler> logger)
|
||||
: IRequestHandler<ReleaseInventoryCommand, InventoryItemDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryItemDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 批次维护处理器。
|
||||
/// </summary>
|
||||
public sealed class UpsertInventoryBatchCommandHandler(
|
||||
IInventoryRepository inventoryRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ILogger<UpsertInventoryBatchCommandHandler> logger)
|
||||
: IRequestHandler<UpsertInventoryBatchCommand, InventoryBatchDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<InventoryBatchDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
using TakeoutSaaS.Domain.Inventory.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory;
|
||||
|
||||
/// <summary>
|
||||
/// 库存映射辅助。
|
||||
/// </summary>
|
||||
public static class InventoryMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// 映射库存 DTO。
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 映射批次 DTO。
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 查询库存批次列表。
|
||||
/// </summary>
|
||||
public sealed record GetInventoryBatchesQuery : IRequest<IReadOnlyList<InventoryBatchDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.App.Inventory.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 按门店与 SKU 查询库存。
|
||||
/// </summary>
|
||||
public sealed record GetInventoryItemQuery : IRequest<InventoryItemDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 门店 ID。
|
||||
/// </summary>
|
||||
public long StoreId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SKU ID。
|
||||
/// </summary>
|
||||
public long ProductSkuId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 库存调整命令验证器。
|
||||
/// </summary>
|
||||
public sealed class AdjustInventoryCommandValidator : AbstractValidator<AdjustInventoryCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 扣减库存命令验证器。
|
||||
/// </summary>
|
||||
public sealed class DeductInventoryCommandValidator : AbstractValidator<DeductInventoryCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 库存锁定命令验证器。
|
||||
/// </summary>
|
||||
public sealed class LockInventoryCommandValidator : AbstractValidator<LockInventoryCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 释放库存命令验证器。
|
||||
/// </summary>
|
||||
public sealed class ReleaseInventoryCommandValidator : AbstractValidator<ReleaseInventoryCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.App.Inventory.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Inventory.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 批次维护命令验证器。
|
||||
/// </summary>
|
||||
public sealed class UpsertInventoryBatchCommandValidator : AbstractValidator<UpsertInventoryBatchCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 新增商户证照。
|
||||
/// </summary>
|
||||
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<MerchantDocumentDto>;
|
||||
@@ -0,0 +1,13 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 新增商户类目。
|
||||
/// </summary>
|
||||
public sealed record CreateMerchantCategoryCommand(
|
||||
[property: Required, MaxLength(64)] string Name,
|
||||
int? DisplayOrder,
|
||||
bool IsActive = true) : IRequest<MerchantCategoryDto>;
|
||||
@@ -0,0 +1,15 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 新建商户合同。
|
||||
/// </summary>
|
||||
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<MerchantContractDto>;
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 删除商户类目。
|
||||
/// </summary>
|
||||
public sealed record DeleteMerchantCategoryCommand([property: Required] long CategoryId) : IRequest<bool>;
|
||||
@@ -0,0 +1,17 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 调整类目排序。
|
||||
/// </summary>
|
||||
public sealed record ReorderMerchantCategoriesCommand(
|
||||
[property: Required, MinLength(1)] IReadOnlyList<MerchantCategoryOrderItem> Items) : IRequest<bool>;
|
||||
|
||||
/// <summary>
|
||||
/// 类目排序条目。
|
||||
/// </summary>
|
||||
public sealed record MerchantCategoryOrderItem(
|
||||
[property: Required] long CategoryId,
|
||||
[property: Range(-1000, 100000)] int DisplayOrder);
|
||||
@@ -0,0 +1,13 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 审核商户入驻。
|
||||
/// </summary>
|
||||
public sealed record ReviewMerchantCommand(
|
||||
[property: Required] long MerchantId,
|
||||
bool Approve,
|
||||
string? Remarks) : IRequest<MerchantDto>;
|
||||
@@ -0,0 +1,14 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 审核商户证照。
|
||||
/// </summary>
|
||||
public sealed record ReviewMerchantDocumentCommand(
|
||||
[property: Required] long MerchantId,
|
||||
[property: Required] long DocumentId,
|
||||
bool Approve,
|
||||
string? Remarks) : IRequest<MerchantDocumentDto>;
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 更新合同状态。
|
||||
/// </summary>
|
||||
public sealed record UpdateMerchantContractStatusCommand(
|
||||
[property: Required] long MerchantId,
|
||||
[property: Required] long ContractId,
|
||||
[property: Required] ContractStatus Status,
|
||||
DateTime? SignedAt,
|
||||
string? Reason) : IRequest<MerchantContractDto>;
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 商户审核日志 DTO。
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 商户类目 DTO。
|
||||
/// </summary>
|
||||
public sealed record MerchantCategoryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 类目标识。
|
||||
/// </summary>
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 类目名称。
|
||||
/// </summary>
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int DisplayOrder { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 商户合同 DTO。
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Application.App.Merchants.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// 商户详情 DTO。
|
||||
/// </summary>
|
||||
public sealed class MerchantDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 基础信息。
|
||||
/// </summary>
|
||||
public MerchantDto Merchant { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 证照列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MerchantDocumentDto> Documents { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 合同列表。
|
||||
/// </summary>
|
||||
public IReadOnlyList<MerchantContractDto> Contracts { get; init; } = [];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 商户证照 DTO。
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 处理证照上传。
|
||||
/// </summary>
|
||||
public sealed class AddMerchantDocumentCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
IIdGenerator idGenerator,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<AddMerchantDocumentCommand, MerchantDocumentDto>
|
||||
{
|
||||
public async Task<MerchantDocumentDto> 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}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 创建类目处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateMerchantCategoryCommandHandler(
|
||||
IMerchantCategoryRepository categoryRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<CreateMerchantCategoryCommand, MerchantCategoryDto>
|
||||
{
|
||||
public async Task<MerchantCategoryDto> 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);
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,10 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
public sealed class CreateMerchantCommandHandler(IMerchantRepository merchantRepository, ILogger<CreateMerchantCommandHandler> logger)
|
||||
: IRequestHandler<CreateMerchantCommand, MerchantDto>
|
||||
{
|
||||
private readonly IMerchantRepository _merchantRepository = merchantRepository;
|
||||
private readonly ILogger<CreateMerchantCommandHandler> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MerchantDto> Handle(CreateMerchantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 创建商户合同。
|
||||
/// </summary>
|
||||
public sealed class CreateMerchantContractCommandHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
IIdGenerator idGenerator,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
: IRequestHandler<CreateMerchantContractCommand, MerchantContractDto>
|
||||
{
|
||||
public async Task<MerchantContractDto> 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}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 删除类目处理器。
|
||||
/// </summary>
|
||||
public sealed class DeleteMerchantCategoryCommandHandler(
|
||||
IMerchantCategoryRepository categoryRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<DeleteMerchantCategoryCommand, bool>
|
||||
{
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
@@ -15,25 +15,21 @@ public sealed class DeleteMerchantCommandHandler(
|
||||
ILogger<DeleteMerchantCommandHandler> logger)
|
||||
: IRequestHandler<DeleteMerchantCommand, bool>
|
||||
{
|
||||
private readonly IMerchantRepository _merchantRepository = merchantRepository;
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider;
|
||||
private readonly ILogger<DeleteMerchantCommandHandler> _logger = logger;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(DeleteMerchantCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 校验存在性
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var existing = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 读取商户审核日志。
|
||||
/// </summary>
|
||||
public sealed class GetMerchantAuditLogsQueryHandler(
|
||||
IMerchantRepository merchantRepository,
|
||||
ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetMerchantAuditLogsQuery, PagedResult<MerchantAuditLogDto>>
|
||||
{
|
||||
public async Task<PagedResult<MerchantAuditLogDto>> 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<MerchantAuditLogDto>(paged, request.Page, request.PageSize, total);
|
||||
}
|
||||
}
|
||||
@@ -12,19 +12,18 @@ namespace TakeoutSaaS.Application.App.Merchants.Handlers;
|
||||
public sealed class GetMerchantByIdQueryHandler(IMerchantRepository merchantRepository, ITenantProvider tenantProvider)
|
||||
: IRequestHandler<GetMerchantByIdQuery, MerchantDto?>
|
||||
{
|
||||
private readonly IMerchantRepository _merchantRepository = merchantRepository;
|
||||
private readonly ITenantProvider _tenantProvider = tenantProvider;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MerchantDto?> Handle(GetMerchantByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken);
|
||||
// 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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user