feat: migrate snowflake ids and refresh migrations

This commit is contained in:
2025-12-02 09:04:37 +08:00
parent 462e15abbb
commit 148475fa43
174 changed files with 8020 additions and 34278 deletions

150
AGENTS.md Normal file
View File

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

View File

@@ -2,6 +2,48 @@
> 目的:在执行 `dotnet ef` 命令时无需硬编码数据库连接,可根据 appsettings 与环境变量自动加载。本文覆盖环境变量设置、配置目录指定等细节。 > 目的:在执行 `dotnet ef` 命令时无需硬编码数据库连接,可根据 appsettings 与环境变量自动加载。本文覆盖环境变量设置、配置目录指定等细节。
## 三库迁移命令 只需更改 SnowflakeIds_App 迁移关键字
> 先生成迁移,再执行数据库更新。启动项目统一用 AdminApi 确保加载最新配置。
### 生成迁移
```bash
# App 主库
dotnet tool run dotnet-ef migrations add SnowflakeIds_App `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj `
--context TakeoutSaaS.Infrastructure.App.Persistence.TakeoutAppDbContext
# Identity 库
dotnet tool run dotnet-ef migrations add SnowflakeIds_Identity `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj `
--context TakeoutSaaS.Infrastructure.Identity.Persistence.IdentityDbContext
# Dictionary 库
dotnet tool run dotnet-ef migrations add SnowflakeIds_Dictionary `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj `
--context TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext
```
### 更新数据库
```bash
dotnet tool run dotnet-ef database update `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj `
--context TakeoutSaaS.Infrastructure.App.Persistence.TakeoutAppDbContext
dotnet tool run dotnet-ef database update `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj `
--context TakeoutSaaS.Infrastructure.Identity.Persistence.IdentityDbContext
dotnet tool run dotnet-ef database update `
--project src/Infrastructure/TakeoutSaaS.Infrastructure/TakeoutSaaS.Infrastructure.csproj `
--startup-project src/Api/TakeoutSaaS.AdminApi/TakeoutSaaS.AdminApi.csproj `
--context TakeoutSaaS.Infrastructure.Dictionary.Persistence.DictionaryDbContext
```
## 一、设计时工厂读取逻辑概述 ## 一、设计时工厂读取逻辑概述
设计时工厂(`DesignTimeDbContextFactoryBase<T>`)按下面顺序解析连接串: 设计时工厂(`DesignTimeDbContextFactoryBase<T>`)按下面顺序解析连接串:
1. 若设置了 `TAKEOUTSAAS_APP_CONNECTION` / `TAKEOUTSAAS_IDENTITY_CONNECTION` / `TAKEOUTSAAS_DICTIONARY_CONNECTION` 等环境变量,则优先使用。 1. 若设置了 `TAKEOUTSAAS_APP_CONNECTION` / `TAKEOUTSAAS_IDENTITY_CONNECTION` / `TAKEOUTSAAS_DICTIONARY_CONNECTION` 等环境变量,则优先使用。

View File

@@ -1,57 +1,57 @@
# System-Level TODOs # TODO Roadmap
> Infrastructure / platform backlog migrated from the former 11th document. Unless noted explicitly, all checklist items remain pending. > 当前列表为原 11 号文档中的待办事项,已迁移到此处并统一以复选框形式标记。若无特殊说明,均尚未完成。
## 1. 閰嶇疆涓庡熀纭€璁炬柦锛堥珮浼橈級 ## 1. 配置与基础设施(高优)
- [x] Development/Production 鏁版嵁搴撹繛鎺ヤ笌 Secret 钀藉湴锛圫taging 鏆備笉闇€瑕侊級銆? - [x] Development/Production 数据库连接与 Secret 落地Staging 暂不需要)。
- [x] Redis 鏈嶅姟閮ㄧ讲瀹屾瘯骞惰褰曢厤缃€? - [x] Redis 服务部署完毕并记录配置。
- [x] RabbitMQ 鏈嶅姟閮ㄧ讲瀹屾瘯骞惰褰曢厤缃€? - [x] RabbitMQ 服务部署完毕并记录配置。
- [x] COS 瀵嗛挜閰嶇疆琛ュ綍瀹屾瘯銆? - [x] COS 密钥配置补录完毕。
- [ ] OSS 瀵嗛挜閰嶇疆琛ュ綍瀹屾瘯锛堝緟閲囪喘锛夈€? - [ ] OSS 密钥配置补录完毕(已忽略,待采购后再补录)。
- [ ] SMS 骞冲彴瀵嗛挜閰嶇疆琛ュ綍瀹屾瘯锛堝緟閲囪喘锛夈€? - [ ] SMS 平台密钥配置补录完毕(已忽略,待采购后再补录)。
- [x] WeChat Mini 绋嬪簭瀵嗛挜閰嶇疆琛ュ綍瀹屾瘯锛圓ppID锛歸x30f91e6afe79f405锛孉ppSecret锛?4324a7f604245301066ba7c3add488e锛屽凡鍚屾鍒?admin/mini 閰嶇疆骞剁櫥璁版洿鏂颁汉锛夈€? - [x] WeChat Mini 程序密钥配置补录完毕AppIDwx30f91e6afe79f405AppSecret64324a7f604245301066ba7c3add488e,已同步到 admin/mini 配置并登记更新人)。
- [x] PostgreSQL 鍩虹瀹炰緥閮ㄧ讲瀹屾瘯骞惰褰曢厤缃€? - [x] PostgreSQL 基础实例部署完毕并记录配置。
- [x] Postgres/Redis 鎺ュ叆鏂囨。 + IaC/鑴氭湰琛ラ綈锛堣 Document/infra/postgres_redis.md 涓?deploy/postgres|redis锛夈€? - [x] Postgres/Redis 接入文档 + IaC/脚本补齐(见 Document/infra/postgres_redis.md deploy/postgres|redis)。
- [x] RabbitMQ/Redis/Hangfire storage scripts available (see deploy/postgres and deploy/redis). - [x] RabbitMQ/Redis/Hangfire storage scripts available (see deploy/postgres and deploy/redis).
- [ ] admin/mini/user/gateway 缃戝叧鍩熷悕銆佽瘉涔︺€丆ORS 鍒楄〃鏁寸悊瀹屾垚銆? - [ ] admin/mini/user/gateway 网关域名、证书、CORS 列表整理完成。(忽略,暂时不用完成)
- [ ] Hangfire Dashboard 鍚敤骞舵柊澧?Admin 瑙掕壊楠岃瘉/缃戝叧鐧藉悕鍗曘€? - [ ] Hangfire Dashboard 启用并新增 Admin 角色验证/网关白名单。(忽略,暂时不用完成)
## 2. 鏁版嵁涓庤縼绉伙紙楂樹紭锛? ## 2. 数据与迁移(高优)
- [x] App/Identity/Dictionary/Hangfire 鍥涗釜 DbContext 鍧囩敓鎴愬垵濮?Migration 骞舵垚鍔?update database銆? - [x] App/Identity/Dictionary/Hangfire 四个 DbContext 均生成初始 Migration 并成功 update database
- [ ] 鍟嗘埛/闂ㄥ簵/鍟嗗搧/璁㈠崟/鏀粯/閰嶉€佺瓑瀹炰綋涓庝粨鍌ㄥ疄鐜板畬鎴愶紝鎻愪緵 CRUD + 鏌ヨ銆? - [ ] 商户/门店/商品/订单/支付/配送等实体与仓储实现完成,提供 CRUD + 查询。
- [ ] 绯荤粺鍙傛暟銆侀粯璁ょ鎴枫€佺鐞嗗憳璐﹀彿銆佸熀纭€瀛楀吀鐨勭瀛愯剼鏈彲閲嶅鎵ц銆? - [ ] 系统参数、默认租户、管理员账号、基础字典的种子脚本可重复执行。
## 3. 绋冲畾鎬т笌璐ㄩ噺 ## 3. 稳定性与质量
- [ ] Dictionary/Identity/Storage/Sms/Messaging/Scheduler 鐨?xUnit+FluentAssertions 鍗曞厓娴嬭瘯妗嗘灦鎼缓銆? - [ ] Dictionary/Identity/Storage/Sms/Messaging/Scheduler xUnit+FluentAssertions 单元测试框架搭建。
- [ ] WebApplicationFactory + Testcontainers 鎷夎捣 Postgres/Redis/RabbitMQ/MinIO 鐨勯泦鎴愭祴璇曟ā鏉裤€? - [ ] WebApplicationFactory + Testcontainers 拉起 Postgres/Redis/RabbitMQ/MinIO 的集成测试模板。
- [ ] .editorconfig銆?globalconfig銆丷oslyn 鍒嗘瀽鍣ㄩ厤缃粨搴撻€氱敤瑙勫垯骞跺惎鐢?CI 妫€鏌ャ€? - [ ] .editorconfig、.globalconfig、Roslyn 分析器配置仓库通用规则并启用 CI 检查。
## 4. 瀹夊叏涓庡悎瑙? ## 4. 安全与合规
- [ ] RBAC 鏉冮檺銆佺鎴烽殧绂汇€佺敤鎴?鏉冮檺娲炲療 API 瀹屾暣婕旂ず骞跺湪 Swagger 涓彁渚涚ず渚嬨€? - [ ] RBAC 权限、租户隔离、用户/权限洞察 API 完整演示并在 Swagger 中提供示例。
- [ ] 鐧诲綍/鍒锋柊娴佺▼澧炲姞 IP 鏍¢獙銆佺鎴烽殧绂汇€侀獙璇佺爜/棰戠巼闄愬埗銆? - [ ] 登录/刷新流程增加 IP 校验、租户隔离、验证码/频率限制。
- [ ] 鐧诲綍/鏉冮檺/鏁忔劅鎿嶄綔鏃ュ織鍙拷婧紝鎻愪緵鏌ヨ鎺ュ彛鎴?Kibana Saved Search銆? - [ ] 登录/权限/敏感操作日志可追溯,提供查询接口或 Kibana Saved Search
- [ ] Secret Store/KeyVault/KMS 绠$悊鏁忔劅閰嶇疆锛岀姝㈠瘑閽ュ啓鍏?Git/鏁版嵁搴撴槑鏂囥€? - [ ] Secret Store/KeyVault/KMS 管理敏感配置,禁止密钥写入 Git/数据库明文。
## 5. 瑙傛祴涓庤繍缁? ## 5. 观测与运维
- [ ] TraceId 璐€氾紝骞跺湪 Serilog 涓緭鍑?Console/File/ELK 涓夌鐩爣銆? - [ ] TraceId 贯通,并在 Serilog 中输出 Console/File/ELK 三种目标。
- [ ] Prometheus exporter 鏆撮湶鍏抽敭鎸囨爣锛?health 鎺㈤拡涓庡憡璀﹁鍒欏悓姝ユ帹閫併€? - [ ] Prometheus exporter 暴露关键指标,/health 探针与告警规则同步推送。
- [ ] PostgreSQL 鍏ㄩ噺/澧為噺澶囦唤鑴氭湰鍙婁竴娆$湡瀹炴仮澶嶆紨缁冩姤鍛娿€? - [ ] PostgreSQL 全量/增量备份脚本及一次真实恢复演练报告。
## 6. 涓氬姟鑳藉姏琛ュ叏 ## 6. 业务能力补全
- [ ] 鍟嗘埛/闂ㄥ簵/鑿滃搧 API 瀹屾垚骞跺湪 MQ 涓姇閫掍笂鏋?鏀粯鎴愬姛浜嬩欢銆? - [ ] 商户/门店/菜品 API 完成并在 MQ 中投递上架/支付成功事件。
- [ ] 閰嶉€佸鎺?API 鏀寔涓嬪崟/鍙栨秷/鏌ヨ骞跺畬鎴愮鍚嶉獙绛句腑闂翠欢銆? - [ ] 配送对接 API 支持下单/取消/查询并完成签名验签中间件。
- [ ] 灏忕▼搴忕鍟嗗搧娴忚銆佷笅鍗曘€佹敮浠樸€佽瘎浠枫€佸浘鐗囩洿浼犵瓑 API 鍙棴鐜窇閫氥€? - [ ] 小程序端商品浏览、下单、支付、评价、图片直传等 API 可闭环跑通。
## 7. 鍓嶅悗鍙?UI 瀵规帴 ## 7. 前后台 UI 对接
- [ ] Admin UI 閫氳繃 OpenAPI 鐢熸垚鎴栨墜鍐欑晫闈紝鎺ュ叆 Hangfire Dashboard/MQ 鐩戞帶鍙妯″紡銆? - [ ] Admin UI 通过 OpenAPI 生成或手写界面,接入 Hangfire Dashboard/MQ 监控只读模式。
- [ ] 灏忕▼搴忕瀹屾垚鐧诲綍銆佽彍鍗曟祻瑙堛€佷笅鍗曘€佹敮浠樸€佺墿娴佽建杩广€佺礌鏉愮洿浼犻棴鐜€? - [ ] 小程序端完成登录、菜单浏览、下单、支付、物流轨迹、素材直传闭环。
## 8. CI/CD 涓庡彂甯? ## 8. CI/CD 与发布
- [ ] CI/CD 娴佹按绾胯鐩栨瀯寤恒€佸彂甯冦€侀潤鎬佹壂鎻忋€佹暟鎹簱杩佺Щ銆? - [ ] CI/CD 流水线覆盖构建、发布、静态扫描、数据库迁移。
- [ ] Dev/Staging/Prod 澶氱幆澧冮厤缃煩闃?+ 鍩虹璁炬柦 IaC 鑴氭湰銆? - [ ] Dev/Staging/Prod 多环境配置矩阵 + 基础设施 IaC 脚本。
- [ ] 鐗堟湰涓庡彂甯冭鏄庢ā鏉挎暣鐞嗗苟鍦ㄤ粨搴撲腑鎻愪緵绀轰緥銆? - [ ] 版本与发布说明模板整理并在仓库中提供示例。
## 9. 鏂囨。涓庣煡璇嗗簱 ## 9. 文档与知识库
- [ ] 鎺ュ彛鏂囨。銆侀鍩熸ā鍨嬨€佸叧閿害鏉熶娇鐢?Markdown 鎴?API Portal 瀹屾暣璁板綍銆? - [ ] 接口文档、领域模型、关键约束使用 Markdown API Portal 完整记录。
- [ ] 杩愯鎵嬪唽鍖呭惈閮ㄧ讲姝ラ銆佽祫婧愭嫇鎵戙€佹晠闅滄帓鏌ユ墜鍐屻€? - [ ] 运行手册包含部署步骤、资源拓扑、故障排查手册。
- [ ] 瀹夊叏鍚堣妯℃澘瑕嗙洊鏁版嵁鍒嗙骇銆佸瘑閽ョ鐞嗐€佸璁℃祦绋嬪苟褰㈡垚鍙鐢ㄨ〃鏍笺€ - [ ] 安全合规模板覆盖数据分级、密钥管理、审计流程并形成可复用表格。

View File

@@ -1,54 +1,53 @@
# Business-Level TODOs # 里程碑待办追踪
> Product & business capability roadmap grouped by milestones; each phase only tracks the scoped backlog to enable staged delivery. > 按“小程序版模块规划”划分四个里程碑;每个里程碑只含对应范围的任务,便于分阶段推进。
--- ---
## Phase 1锛堝綋鍓嶉樁娈碉級锛氱鎴?鍟嗗鍏ラ┗銆侀棬搴椾笌鑿滃搧銆佹壂鐮佸爞椋熴€佸熀纭€涓嬪崟鏀粯銆侀璐嚜鎻愩€佺涓夋柟閰嶉€侀鏋? ## Phase 1(当前阶段):租户/商家入驻、门店与菜品、扫码堂食、基础下单支付、预购自提、第三方配送骨架
- [ ] 绠$悊绔鎴?API锛氭敞鍐屻€佸疄鍚嶈璇併€佸椁愯闃?缁垂/鍗囬檷閰嶃€佸鏍告祦锛孲wagger 鈮? 涓鐐癸紝鍚鏍告棩蹇椼€? - [ ] 管理端租户 API注册、实名认证、套餐订阅/续费/升降配、审核流Swagger ≥6 个端点,含审核日志。
- [ ] 鍟嗗鍏ラ┗ API锛氳瘉鐓т笂浼犮€佸悎鍚岀鐞嗐€佺被鐩€夋嫨锛岄┍鍔ㄥ緟瀹?瀹℃牳/椹冲洖/閫氳繃鐘舵€佹満锛屾枃浠舵寔涔呭湪 COS銆? - [ ] 商家入驻 API证照上传、合同管理、类目选择驱动待审/审核/驳回/通过状态机,文件持久在 COS
- [ ] RBAC 妯℃澘锛氬钩鍙扮鐞嗗憳銆佺鎴风鐞嗗憳銆佸簵闀裤€佸簵鍛樺洓瑙掕壊妯℃澘锛汚PI 鍙鍒跺苟鍏佽绉熸埛鑷畾涔夋墿灞曘€? - [ ] RBAC 模板平台管理员、租户管理员、店长、店员四角色模板API 可复制并允许租户自定义扩展。
- [ ] 閰嶉涓庡椁愶細TenantPackage CRUD銆佽闃?缁垂/閰嶉鏍¢獙锛堥棬搴?璐﹀彿/鐭俊/閰嶉€佸崟閲忥級锛岃秴棰濊繑鍥?409 骞惰褰?TenantQuotaUsage銆? - [ ] 配额与套餐:TenantPackage CRUD、订阅/续费/配额校验(门店/账号/短信/配送单量),超额返回 409 并记录 TenantQuotaUsage
- [ ] 绉熸埛杩愯惀闈㈡澘锛氭瑺璐?鍒版湡鍛婅銆佽处鍗曞垪琛ㄣ€佸叕鍛婇€氱煡鎺ュ彛锛屾敮鎸佸凡璇荤姸鎬佸苟鍦?Admin UI 灞曠ず銆? - [ ] 租户运营面板:欠费/到期告警、账单列表、公告通知接口,支持已读状态并在 Admin UI 展示。
- [ ] 闂ㄥ簵绠$悊锛歋tore/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 瀹屾暣锛屽惈 GeoJSON 閰嶉€佽寖鍥村強鑳藉姏寮€鍏炽€? - [ ] 门店管理Store/StoreBusinessHour/StoreDeliveryZone/StoreHoliday CRUD 完整,含 GeoJSON 配送范围及能力开关。
- [ ] 妗岀爜绠$悊锛氭壒閲忕敓鎴愭鐮併€佺粦瀹氬尯鍩?瀹归噺銆佸鍑轰簩缁寸爜 ZIP锛圥OST /api/admin/stores/{id}/tables 鍙笅杞斤級銆? - [ ] 桌码管理:批量生成桌码、绑定区域/容量、导出二维码 ZIPPOST /api/admin/stores/{id}/tables 可下载)。
- [ ] 鍛樺伐鎺掔彮锛氬垱寤哄憳宸ャ€佺粦瀹氶棬搴楄鑹层€佺淮鎶?StoreEmployeeShift锛屽彲鏌ヨ鏈潵 7 鏃ユ帓鐝€? - [ ] 员工排班:创建员工、绑定门店角色、维护 StoreEmployeeShift可查询未来 7 日排班。
- [ ] 妗岀爜鎵爜鍏ュ彛锛歁ini 绔В鏋愪簩缁寸爜锛孏ET /api/mini/tables/{code}/context 杩斿洖闂ㄥ簵銆佹鍙般€佸叕鍛娿€? - [ ] 桌码扫码入口Mini 端解析二维码GET /api/mini/tables/{code}/context 返回门店、桌台、公告。
- [ ] 鑿滃搧寤烘ā锛氬垎绫汇€丼PU銆丼KU銆佽鏍?鍔犳枡缁勩€佷环鏍肩瓥鐣ャ€佸獟璧?CRUD + 涓婁笅鏋舵祦绋嬶紱Mini 绔彲鎷夊彇瀹屾暣 JSON銆? - [ ] 菜品建模分类、SPU、SKU、规格/加料组、价格策略、媒资 CRUD + 上下架流程Mini 端可拉取完整 JSON
- [ ] 搴撳瓨浣撶郴锛歋KU 搴撳瓨銆佹壒娆°€佽皟鏁淬€佸敭缃勭鐞嗭紝鏀寔棰勫敭/妗f湡閿佸畾骞跺湪璁㈠崟涓墸鍑?閲婃斁銆? - [ ] 库存体系SKU 库存、批次、调整、售罄管理,支持预售/档期锁定并在订单中扣减/释放。
- [ ] 鑷彁妗湡锛氶棬搴楅厤缃嚜鎻愭椂闂寸獥銆佸閲忋€佹埅鍗曟椂闂达紱Mini 绔嵁姝ら檺鍒朵笅鍗曟椂闂淬€? - [ ] 自提档期门店配置自提时间窗、容量、截单时间Mini 端据此限制下单时间。
- [ ] 璐墿杞︽湇鍔★細ShoppingCart/CartItem/CartItemAddon API 鏀寔骞跺彂閿併€侀檺璐€佸埜/绉垎棰勬牎楠岋紝淇濊瘉骞跺彂鏃犺剰鏁版嵁銆? - [ ] 购物车服务:ShoppingCart/CartItem/CartItemAddon API 支持并发锁、限购、券/积分预校验,保证并发无脏数据。
- [ ] 璁㈠崟涓庢敮浠橈細鍫傞/鑷彁/閰嶉€佷笅鍗曘€佸井淇?鏀粯瀹濇敮浠樸€佷紭鎯犲埜/绉垎鎶垫墸銆佽鍗曠姸鎬佹満涓庨€氱煡閾捐矾榻愬叏銆? - [ ] 订单与支付:堂食/自提/配送下单、微信/支付宝支付、优惠券/积分抵扣、订单状态机与通知链路齐全。
- [ ] 妗屽彴璐﹀崟锛氬悎鍗?鎷嗗崟銆佺粨璐︺€佺數瀛愬皬绁ㄣ€佹鍙伴噴鏀撅紝瀹屾垚缁撹处鍚庢仮澶?Idle 骞剁敓鎴愮エ鎹?URL銆? - [ ] 桌台账单:合单/拆单、结账、电子小票、桌台释放,完成结账后恢复 Idle 并生成票据 URL
- [ ] 鑷厤閫侀鏋讹細楠戞墜绠悊銆佸彇閫佷欢淇℃伅褰曞叆銆佽垂鐢ㄨˉ璐磋褰曪紝Admin 绔彲娲惧崟骞舵洿鏂?DeliveryOrder銆? - [ ] 自配送骨架骑手管理、取送件信息录入、费用补贴记录Admin 端可派单并更新 DeliveryOrder
- [ ] 绗笁鏂归厤閫佹娊璞★細缁熶竴涓嬪崟/鍙栨秷/鍔犱环/鏌ヨ鎺ュ彛锛屾敮鎸佽揪杈俱€佺編鍥€侀棯閫佺瓑锛屽惈鍥炶皟楠岀涓庡紓甯歌ˉ鍋块鏋躲€? - [ ] 第三方配送抽象:统一下单/取消/加价/查询接口,支持达达、美团、闪送等,含回调验签与异常补偿骨架。
- [ ] 棰勮喘鑷彁鏍搁攢锛氭彁璐х爜鐢熸垚銆佹墜鏈哄彿/浜岀淮鐮佹牳閿€銆佽嚜鎻愭煖/鍓嶅彴娴佺▼锛岃秴鏃惰嚜鍔ㄥ彇娑堟垨閫€娆撅紝璁板綍鎿嶄綔鑰呬笌鏃堕棿銆? - [ ] 预购自提核销:提货码生成、手机号/二维码核销、自提柜/前台流程,超时自动取消或退款,记录操作者与时间。
- [ ] 鎸囨爣涓庢棩蹇楋細Prometheus 杈撳嚭璁㈠崟鍒涘缓銆佹敮浠樻垚鍔熺巼銆侀厤閫佸洖璋冭€楁椂绛夛紝Grafana 鈮? 涓浘琛紱鍏抽敭娴佺▼鏃ュ織璁板綍 TraceId + 涓氬姟 ID銆? - [ ] 指标与日志:Prometheus 输出订单创建、支付成功率、配送回调耗时等Grafana ≥8 个图表;关键流程日志记录 TraceId + 业务 ID
- [ ] 娴嬭瘯锛歅hase 1 鏍稿績 API 鍏峰 鈮?0 鏉¤嚜鍔ㄥ寲鐢ㄤ緥锛堝崟鍏?+ 闆嗘垚锛夛紝瑕嗙洊绉熸埛鈫掑晢鎴封啋涓嬪崟閾捐矾銆? - [ ] 测试Phase 1 核心 API 具备 ≥30 条自动化用例(单元 + 集成),覆盖租户→商户→下单链路。
--- ---
## Phase 2锛堜笅涓€闃舵锛夛細鎷煎崟銆佷紭鎯犲埜涓庡熀纭€钀ラ攢銆佷細鍛樼Н鍒?浼氬憳鏃ャ€佸鏈嶈亰澶┿€佸悓鍩庤嚜閰嶉€佽皟搴︺€佹悳绱?
- [ ] 鎷煎崟寮曟搸锛欸roupOrder/Participant CRUD銆佸彂璧?鍔犲叆/鎴愬洟鏉′欢銆佽嚜鍔ㄨВ鏁d笌閫€娆俱€佸洟鍐呮秷鎭笌鎻愰啋銆?
- [ ] 浼樻儬鍒镐笌鍩虹钀ラ攢锛氭ā鏉跨鐞嗐€侀鍒搞€佹牳閿€銆佸簱瀛?鏈夋晥鏈?鍙犲姞瑙勫垯锛屽熀纭€鎶藉/绉掓潃/婊″噺娲诲姩銆?
- [ ] 浼氬憳涓庣Н鍒嗭細浼氬憳妗f銆佺瓑绾?鎴愰暱鍊笺€佷細鍛樻棩閫氱煡锛涚Н鍒嗚幏鍙?娑堣€椼€佹湁鏁堟湡銆侀粦鍚嶅崟銆?
- [ ] 瀹㈡湇鑱婂ぉ锛氬疄鏃朵細璇濄€佹満鍣ㄤ汉/浜哄伐鍒囨崲銆佹帓闃?杞帴銆佹秷鎭ā鏉裤€佹晱鎰熻瘝瀹℃煡銆佸伐鍗曟祦杞笌璇勪环銆?
- [ ] 鍚屽煄鑷厤閫佽皟搴︼細楠戞墜鏅鸿兘鎸囨淳銆佽矾绾夸及鏃躲€佹棤鎺ヨЕ閰嶉€併€佽垂鐢ㄨˉ璐寸瓥鐣ャ€佽皟搴︾湅鏉裤€?
- [ ] 鎼滅储锛氶棬搴?鑿滃搧/娲诲姩/浼樻儬鍒告悳绱紝杩囨护/鎺掑簭銆佺儹闂?鍘嗗彶璁板綍銆佽仈鎯充笌绾犻敊銆?
## Phase 2下一阶段拼单、优惠券与基础营销、会员积分/会员日、客服聊天、同城自配送调度、搜索
- [ ] 拼单引擎GroupOrder/Participant CRUD、发起/加入/成团条件、自动解散与退款、团内消息与提醒。
- [ ] 优惠券与基础营销:模板管理、领券、核销、库存/有效期/叠加规则,基础抽奖/秒杀/满减活动。
- [ ] 会员与积分:会员档案、等级/成长值、会员日通知;积分获取/消耗、有效期、黑名单。
- [ ] 客服聊天:实时会话、机器人/人工切换、排队/转接、消息模板、敏感词审查、工单流转与评价。
- [ ] 同城自配送调度:骑手智能指派、路线估时、无接触配送、费用补贴策略、调度看板。
- [ ] 搜索:门店/菜品/活动/优惠券搜索,过滤/排序、热门/历史记录、联想与纠错。
--- ---
## Phase 3锛氬垎閿€杩斿埄銆佺鍒版墦鍗°€侀绾﹂璁€佸湴鍥惧鑸€佺ぞ鍖恒€侀珮闃惰惀閿€銆侀鎺т笌琛ュ伩
- [ ] 鍒嗛攢杩斿埄锛欰ffiliatePartner/Order/Payout 绠$悊锛屼剑閲戦樁姊€佺粨绠楀懆鏈熴€佺◣鍔′俊鎭€佽繚瑙勫鐞嗐€?
- [ ] 绛惧埌鎵撳崱锛欳heckInCampaign/Record銆佽繛绛惧鍔便€佽ˉ绛俱€佺Н鍒?鍒?鎴愰暱鍊煎鍔便€佸弽浣滃紛鏈哄埗銆?
- [ ] 棰勭害棰勮锛氭。鏈?璧勬簮鍗犵敤銆侀绾︿笅鍗?鏀粯銆佹彁閱?鏀规湡/鍙栨秷銆佸埌搴楁牳閿€涓庡饱绾﹁褰曘€?
- [ ] 鍦板浘瀵艰埅鎵╁睍锛氶檮杩戦棬搴?鎺ㄨ崘銆佽窛绂?璺嚎瑙勫垝銆佽烦杞師鐢熷鑸€佸鑸姹傚煁鐐广€?
- [ ] 绀惧尯锛氬姩鎬佸彂甯冦€佽瘎璁恒€佺偣璧炪€佽瘽棰?鏍囩銆佸浘鐗?瑙嗛瀹℃牳銆佷妇鎶ヤ笌椋庢帶锛屽簵閾哄彛纰戝睍绀恒€?
- [ ] 楂橀樁钀ラ攢锛氱鏉€/鎶藉/瑁傚彉銆佽鍙樻捣鎶ャ€佺垎娆炬帹鑽愪綅銆佸娓犻亾鎶曟斁鍒嗘瀽銆?
- [ ] 椋庢帶涓庡璁★細榛戝悕鍗曘€侀鐜囬檺鍒躲€佸紓甯歌涓虹洃鎺с€佸璁℃棩蹇椼€佽ˉ鍋夸笌鍛婅浣撶郴銆?
## Phase 3分销返利、签到打卡、预约预订、地图导航、社区、高阶营销、风控与补偿
- [ ] 分销返利AffiliatePartner/Order/Payout 管理,佣金阶梯、结算周期、税务信息、违规处理。
- [ ] 签到打卡CheckInCampaign/Record、连签奖励、补签、积分/券/成长值奖励、反作弊机制。
- [ ] 预约预订:档期/资源占用、预约下单/支付、提醒/改期/取消、到店核销与履约记录。
- [ ] 地图导航扩展:附近门店/推荐、距离/路线规划、跳转原生导航、导航请求埋点。
- [ ] 社区:动态发布、评论、点赞、话题/标签、图片/视频审核、举报与风控,店铺口碑展示。
- [ ] 高阶营销:秒杀/抽奖/裂变、裂变海报、爆款推荐位、多渠道投放分析。
- [ ] 风控与审计:黑名单、频率限制、异常行为监控、审计日志、补偿与告警体系。
--- ---
## Phase 4锛氭€ц兘浼樺寲銆佺紦瀛樸€佽繍钀ュぇ鐩樸€佹祴璇曚笌鏂囨。銆佷笂绾夸笌鐩戞帶
- [ ] 鎬ц兘涓庣紦瀛橈細鐑偣鎺ュ彛缂撳瓨銆佹參鏌ヨ娌荤悊銆佹壒澶勭悊浼樺寲銆佸紓姝ュ寲鏀归€犮€?
- [ ] 鍙潬鎬э細骞傜瓑涓庨噸璇曠瓥鐣ャ€佷换鍔¤皟搴﹁ˉ鍋裤€侀摼璺拷韪€佸憡璀﹁仈鍔ㄣ€?
- [ ] 杩愯惀澶х洏锛氫氦鏄?钀ラ攢/灞ョ害/鐢ㄦ埛缁村害鐨勭粏鍒嗘姤琛ㄣ€丟MV/鎴愭湰/姣涘埄鍒嗘瀽銆?
- [ ] 鏂囨。涓庢祴璇曪細瀹屾暣娴嬭瘯鐭╅樀銆佹€ц兘娴嬭瘯鎶ュ憡銆佷笂绾挎墜鍐屻€佸洖婊氭柟妗堛€?
- [ ] 鐩戞帶涓庤繍缁达細涓婄嚎鍙戝竷娴佺▼銆佺伆搴?鍥炴粴绛栫暐銆佺郴缁熺ǔ瀹氭€ф寚鏍囥€?4x7 鐩戞帶涓庡憡璀︺€?
## Phase 4性能优化、缓存、运营大盘、测试与文档、上线与监控
- [ ] 性能与缓存:热点接口缓存、慢查询治理、批处理优化、异步化改造。
- [ ] 可靠性:幂等与重试策略、任务调度补偿、链路追踪、告警联动。
- [ ] 运营大盘:交易/营销/履约/用户维度的细分报表、GMV/成本/毛利分析。
- [ ] 文档与测试:完整测试矩阵、性能测试报告、上线手册、回滚方案。
- [ ] 监控与运维:上线发布流程、灰度/回滚策略、系统稳定性指标、24x7 监控与告警。

View File

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

View File

@@ -67,7 +67,7 @@ public sealed class AuthController : BaseApiController
public async Task<ApiResponse<CurrentUserProfile>> GetProfile(CancellationToken cancellationToken) public async Task<ApiResponse<CurrentUserProfile>> GetProfile(CancellationToken cancellationToken)
{ {
var userId = User.GetUserId(); var userId = User.GetUserId();
if (userId == Guid.Empty) if (userId == 0)
{ {
return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"); return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
} }

View File

@@ -55,10 +55,10 @@ public sealed class DictionaryController : BaseApiController
/// <summary> /// <summary>
/// 更新字典分组。 /// 更新字典分组。
/// </summary> /// </summary>
[HttpPut("{groupId:guid}")] [HttpPut("{groupId:long}")]
[PermissionAuthorize("dictionary:group:update")] [PermissionAuthorize("dictionary:group:update")]
[ProducesResponseType(typeof(ApiResponse<DictionaryGroupDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<DictionaryGroupDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<DictionaryGroupDto>> UpdateGroup(Guid groupId, [FromBody] UpdateDictionaryGroupRequest request, CancellationToken cancellationToken) public async Task<ApiResponse<DictionaryGroupDto>> UpdateGroup(long groupId, [FromBody] UpdateDictionaryGroupRequest request, CancellationToken cancellationToken)
{ {
var group = await _dictionaryAppService.UpdateGroupAsync(groupId, request, cancellationToken); var group = await _dictionaryAppService.UpdateGroupAsync(groupId, request, cancellationToken);
return ApiResponse<DictionaryGroupDto>.Ok(group); return ApiResponse<DictionaryGroupDto>.Ok(group);
@@ -67,10 +67,10 @@ public sealed class DictionaryController : BaseApiController
/// <summary> /// <summary>
/// 删除字典分组。 /// 删除字典分组。
/// </summary> /// </summary>
[HttpDelete("{groupId:guid}")] [HttpDelete("{groupId:long}")]
[PermissionAuthorize("dictionary:group:delete")] [PermissionAuthorize("dictionary:group:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> DeleteGroup(Guid groupId, CancellationToken cancellationToken) public async Task<ApiResponse<object>> DeleteGroup(long groupId, CancellationToken cancellationToken)
{ {
await _dictionaryAppService.DeleteGroupAsync(groupId, cancellationToken); await _dictionaryAppService.DeleteGroupAsync(groupId, cancellationToken);
return ApiResponse.Success(); return ApiResponse.Success();
@@ -79,10 +79,10 @@ public sealed class DictionaryController : BaseApiController
/// <summary> /// <summary>
/// 创建字典项。 /// 创建字典项。
/// </summary> /// </summary>
[HttpPost("{groupId:guid}/items")] [HttpPost("{groupId:long}/items")]
[PermissionAuthorize("dictionary:item:create")] [PermissionAuthorize("dictionary:item:create")]
[ProducesResponseType(typeof(ApiResponse<DictionaryItemDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<DictionaryItemDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<DictionaryItemDto>> CreateItem(Guid groupId, [FromBody] CreateDictionaryItemRequest request, CancellationToken cancellationToken) public async Task<ApiResponse<DictionaryItemDto>> CreateItem(long groupId, [FromBody] CreateDictionaryItemRequest request, CancellationToken cancellationToken)
{ {
request.GroupId = groupId; request.GroupId = groupId;
var item = await _dictionaryAppService.CreateItemAsync(request, cancellationToken); var item = await _dictionaryAppService.CreateItemAsync(request, cancellationToken);
@@ -92,10 +92,10 @@ public sealed class DictionaryController : BaseApiController
/// <summary> /// <summary>
/// 更新字典项。 /// 更新字典项。
/// </summary> /// </summary>
[HttpPut("items/{itemId:guid}")] [HttpPut("items/{itemId:long}")]
[PermissionAuthorize("dictionary:item:update")] [PermissionAuthorize("dictionary:item:update")]
[ProducesResponseType(typeof(ApiResponse<DictionaryItemDto>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<DictionaryItemDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<DictionaryItemDto>> UpdateItem(Guid itemId, [FromBody] UpdateDictionaryItemRequest request, CancellationToken cancellationToken) public async Task<ApiResponse<DictionaryItemDto>> UpdateItem(long itemId, [FromBody] UpdateDictionaryItemRequest request, CancellationToken cancellationToken)
{ {
var item = await _dictionaryAppService.UpdateItemAsync(itemId, request, cancellationToken); var item = await _dictionaryAppService.UpdateItemAsync(itemId, request, cancellationToken);
return ApiResponse<DictionaryItemDto>.Ok(item); return ApiResponse<DictionaryItemDto>.Ok(item);
@@ -104,10 +104,10 @@ public sealed class DictionaryController : BaseApiController
/// <summary> /// <summary>
/// 删除字典项。 /// 删除字典项。
/// </summary> /// </summary>
[HttpDelete("items/{itemId:guid}")] [HttpDelete("items/{itemId:long}")]
[PermissionAuthorize("dictionary:item:delete")] [PermissionAuthorize("dictionary:item:delete")]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public async Task<ApiResponse<object>> DeleteItem(Guid itemId, CancellationToken cancellationToken) public async Task<ApiResponse<object>> DeleteItem(long itemId, CancellationToken cancellationToken)
{ {
await _dictionaryAppService.DeleteItemAsync(itemId, cancellationToken); await _dictionaryAppService.DeleteItemAsync(itemId, cancellationToken);
return ApiResponse.Success(); return ApiResponse.Success();

View File

@@ -0,0 +1,72 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Merchants.Commands;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Domain.Merchants.Enums;
using TakeoutSaaS.Module.Authorization.Attributes;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.AdminApi.Controllers;
/// <summary>
/// 商户管理。
/// </summary>
[ApiVersion("1.0")]
[Authorize]
[Route("api/admin/v{version:apiVersion}/merchants")]
public sealed class MerchantsController : BaseApiController
{
private readonly IMediator _mediator;
/// <summary>
/// 初始化控制器。
/// </summary>
public MerchantsController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// 创建商户。
/// </summary>
[HttpPost]
[PermissionAuthorize("merchant:create")]
[ProducesResponseType(typeof(ApiResponse<MerchantDto>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MerchantDto>> Create([FromBody] CreateMerchantCommand command, CancellationToken cancellationToken)
{
var result = await _mediator.Send(command, cancellationToken);
return ApiResponse<MerchantDto>.Ok(result);
}
/// <summary>
/// 查询商户列表。
/// </summary>
[HttpGet]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<MerchantDto>>), StatusCodes.Status200OK)]
public async Task<ApiResponse<IReadOnlyList<MerchantDto>>> List([FromQuery] MerchantStatus? status, CancellationToken cancellationToken)
{
var result = await _mediator.Send(new SearchMerchantsQuery { Status = status }, cancellationToken);
return ApiResponse<IReadOnlyList<MerchantDto>>.Ok(result);
}
/// <summary>
/// 获取商户详情。
/// </summary>
[HttpGet("{merchantId:long}")]
[PermissionAuthorize("merchant:read")]
[ProducesResponseType(typeof(ApiResponse<MerchantDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
public async Task<ApiResponse<MerchantDto>> Detail(long merchantId, CancellationToken cancellationToken)
{
var result = await _mediator.Send(new GetMerchantByIdQuery { MerchantId = merchantId }, cancellationToken);
return result == null
? ApiResponse<MerchantDto>.Error(ErrorCodes.NotFound, "商户不存在")
: ApiResponse<MerchantDto>.Ok(result);
}
}

View File

@@ -6,10 +6,12 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Serilog; using Serilog;
using TakeoutSaaS.Application.App.Extensions;
using TakeoutSaaS.Application.Identity.Extensions; using TakeoutSaaS.Application.Identity.Extensions;
using TakeoutSaaS.Application.Messaging.Extensions; using TakeoutSaaS.Application.Messaging.Extensions;
using TakeoutSaaS.Application.Sms.Extensions; using TakeoutSaaS.Application.Sms.Extensions;
using TakeoutSaaS.Application.Storage.Extensions; using TakeoutSaaS.Application.Storage.Extensions;
using TakeoutSaaS.Infrastructure.App.Extensions;
using TakeoutSaaS.Infrastructure.Identity.Extensions; using TakeoutSaaS.Infrastructure.Identity.Extensions;
using TakeoutSaaS.Module.Authorization.Extensions; using TakeoutSaaS.Module.Authorization.Extensions;
using TakeoutSaaS.Module.Dictionary.Extensions; using TakeoutSaaS.Module.Dictionary.Extensions;
@@ -40,6 +42,8 @@ builder.Services.AddSharedSwagger(options =>
}); });
builder.Services.AddIdentityApplication(); builder.Services.AddIdentityApplication();
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableAdminSeed: true); builder.Services.AddIdentityInfrastructure(builder.Configuration, enableAdminSeed: true);
builder.Services.AddAppInfrastructure(builder.Configuration);
builder.Services.AddAppApplication();
builder.Services.AddJwtAuthentication(builder.Configuration); builder.Services.AddJwtAuthentication(builder.Configuration);
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddPermissionAuthorization(); builder.Services.AddPermissionAuthorization();

View File

@@ -8,6 +8,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" /> <PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Web\TakeoutSaaS.Shared.Web.csproj" /> <ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Web\TakeoutSaaS.Shared.Web.csproj" />

View File

@@ -149,5 +149,39 @@
"WorkerCount": 5, "WorkerCount": 5,
"DashboardEnabled": false, "DashboardEnabled": false,
"DashboardPath": "/hangfire" "DashboardPath": "/hangfire"
},
"App": {
"Seed": {
"Enabled": true,
"DefaultTenant": {
"TenantId": 1000000000001,
"Code": "demo",
"Name": "Demo租户",
"ShortName": "Demo",
"ContactName": "DemoAdmin",
"ContactPhone": "13800000000"
},
"DictionaryGroups": [
{
"Code": "order_status",
"Name": "订单状态",
"Scope": "Business",
"Items": [
{ "Key": "pending", "Value": "待支付", "SortOrder": 10 },
{ "Key": "paid", "Value": "已支付", "SortOrder": 20 },
{ "Key": "finished", "Value": "已完成", "SortOrder": 30 }
]
},
{
"Code": "store_tags",
"Name": "门店标签",
"Scope": "Business",
"Items": [
{ "Key": "hot", "Value": "热门", "SortOrder": 10 },
{ "Key": "new", "Value": "新店", "SortOrder": 20 }
]
}
]
}
} }
} }

View File

@@ -41,7 +41,7 @@ public sealed class MeController : BaseApiController
public async Task<ApiResponse<CurrentUserProfile>> Get(CancellationToken cancellationToken) public async Task<ApiResponse<CurrentUserProfile>> Get(CancellationToken cancellationToken)
{ {
var userId = User.GetUserId(); var userId = User.GetUserId();
if (userId == Guid.Empty) if (userId == 0)
{ {
return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"); return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 获取商户详情查询处理器。
/// </summary>
public sealed class GetMerchantByIdQueryHandler(IMerchantRepository merchantRepository, ITenantProvider tenantProvider)
: IRequestHandler<GetMerchantByIdQuery, MerchantDto?>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
/// <inheritdoc />
public async Task<MerchantDto?> Handle(GetMerchantByIdQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var merchant = await _merchantRepository.FindByIdAsync(request.MerchantId, tenantId, cancellationToken);
if (merchant == null)
{
return null;
}
return new MerchantDto
{
Id = merchant.Id,
TenantId = merchant.TenantId,
BrandName = merchant.BrandName,
BrandAlias = merchant.BrandAlias,
LogoUrl = merchant.LogoUrl,
Category = merchant.Category,
ContactPhone = merchant.ContactPhone,
ContactEmail = merchant.ContactEmail,
Status = merchant.Status,
JoinedAt = merchant.JoinedAt
};
}
}

View File

@@ -0,0 +1,42 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Application.App.Merchants.Queries;
using TakeoutSaaS.Domain.Merchants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Merchants.Handlers;
/// <summary>
/// 商户列表查询处理器。
/// </summary>
public sealed class SearchMerchantsQueryHandler(
IMerchantRepository merchantRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SearchMerchantsQuery, IReadOnlyList<MerchantDto>>
{
private readonly IMerchantRepository _merchantRepository = merchantRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
/// <inheritdoc />
public async Task<IReadOnlyList<MerchantDto>> Handle(SearchMerchantsQuery request, CancellationToken cancellationToken)
{
var tenantId = _tenantProvider.GetCurrentTenantId();
var merchants = await _merchantRepository.SearchAsync(tenantId, request.Status, cancellationToken);
return merchants
.Select(merchant => new MerchantDto
{
Id = merchant.Id,
TenantId = merchant.TenantId,
BrandName = merchant.BrandName,
BrandAlias = merchant.BrandAlias,
LogoUrl = merchant.LogoUrl,
Category = merchant.Category,
ContactPhone = merchant.ContactPhone,
ContactEmail = merchant.ContactEmail,
Status = merchant.Status,
JoinedAt = merchant.JoinedAt
})
.ToList();
}
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 按 ID 获取商户。
/// </summary>
public sealed class GetMerchantByIdQuery : IRequest<MerchantDto?>
{
/// <summary>
/// 商户 ID。
/// </summary>
public long MerchantId { get; init; }
}

View File

@@ -0,0 +1,16 @@
using MediatR;
using TakeoutSaaS.Application.App.Merchants.Dto;
using TakeoutSaaS.Domain.Merchants.Enums;
namespace TakeoutSaaS.Application.App.Merchants.Queries;
/// <summary>
/// 搜索商户列表。
/// </summary>
public sealed class SearchMerchantsQuery : IRequest<IReadOnlyList<MerchantDto>>
{
/// <summary>
/// 按状态过滤。
/// </summary>
public MerchantStatus? Status { get; init; }
}

View File

@@ -10,17 +10,17 @@ public interface IDictionaryAppService
{ {
Task<DictionaryGroupDto> CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default); Task<DictionaryGroupDto> CreateGroupAsync(CreateDictionaryGroupRequest request, CancellationToken cancellationToken = default);
Task<DictionaryGroupDto> UpdateGroupAsync(Guid groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default); Task<DictionaryGroupDto> UpdateGroupAsync(long groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default);
Task DeleteGroupAsync(Guid groupId, CancellationToken cancellationToken = default); Task DeleteGroupAsync(long groupId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DictionaryGroupDto>> SearchGroupsAsync(DictionaryGroupQuery request, CancellationToken cancellationToken = default); Task<IReadOnlyList<DictionaryGroupDto>> SearchGroupsAsync(DictionaryGroupQuery request, CancellationToken cancellationToken = default);
Task<DictionaryItemDto> CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default); Task<DictionaryItemDto> CreateItemAsync(CreateDictionaryItemRequest request, CancellationToken cancellationToken = default);
Task<DictionaryItemDto> UpdateItemAsync(Guid itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default); Task<DictionaryItemDto> UpdateItemAsync(long itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default);
Task DeleteItemAsync(Guid itemId, CancellationToken cancellationToken = default); Task DeleteItemAsync(long itemId, CancellationToken cancellationToken = default);
Task<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>> GetCachedItemsAsync(DictionaryBatchQueryRequest request, CancellationToken cancellationToken = default); Task<IReadOnlyDictionary<string, IReadOnlyList<DictionaryItemDto>>> GetCachedItemsAsync(DictionaryBatchQueryRequest request, CancellationToken cancellationToken = default);
} }

View File

@@ -13,15 +13,15 @@ public interface IDictionaryCache
/// <summary> /// <summary>
/// 获取缓存。 /// 获取缓存。
/// </summary> /// </summary>
Task<IReadOnlyList<DictionaryItemDto>?> GetAsync(Guid tenantId, string code, CancellationToken cancellationToken = default); Task<IReadOnlyList<DictionaryItemDto>?> GetAsync(long tenantId, string code, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 写入缓存。 /// 写入缓存。
/// </summary> /// </summary>
Task SetAsync(Guid tenantId, string code, IReadOnlyList<DictionaryItemDto> items, CancellationToken cancellationToken = default); Task SetAsync(long tenantId, string code, IReadOnlyList<DictionaryItemDto> items, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 移除缓存。 /// 移除缓存。
/// </summary> /// </summary>
Task RemoveAsync(Guid tenantId, string code, CancellationToken cancellationToken = default); Task RemoveAsync(long tenantId, string code, CancellationToken cancellationToken = default);
} }

View File

@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.Dictionary.Contracts; namespace TakeoutSaaS.Application.Dictionary.Contracts;
@@ -11,7 +13,8 @@ public sealed class CreateDictionaryItemRequest
/// 所属分组 ID。 /// 所属分组 ID。
/// </summary> /// </summary>
[Required] [Required]
public Guid GroupId { get; set; } [JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long GroupId { get; set; }
/// <summary> /// <summary>
/// 字典项键。 /// 字典项键。

View File

@@ -1,5 +1,8 @@
using TakeoutSaaS.Domain.Dictionary.Enums; using TakeoutSaaS.Domain.Dictionary.Enums;
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.Dictionary.Models; namespace TakeoutSaaS.Application.Dictionary.Models;
/// <summary> /// <summary>
@@ -7,7 +10,8 @@ namespace TakeoutSaaS.Application.Dictionary.Models;
/// </summary> /// </summary>
public sealed class DictionaryGroupDto public sealed class DictionaryGroupDto
{ {
public Guid Id { get; init; } [JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
public string Code { get; init; } = string.Empty; public string Code { get; init; } = string.Empty;

View File

@@ -1,3 +1,6 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.Dictionary.Models; namespace TakeoutSaaS.Application.Dictionary.Models;
/// <summary> /// <summary>
@@ -5,9 +8,11 @@ namespace TakeoutSaaS.Application.Dictionary.Models;
/// </summary> /// </summary>
public sealed class DictionaryItemDto public sealed class DictionaryItemDto
{ {
public Guid Id { get; init; } [JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long Id { get; init; }
public Guid GroupId { get; init; } [JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long GroupId { get; init; }
public string Key { get; init; } = string.Empty; public string Key { get; init; } = string.Empty;

View File

@@ -47,7 +47,7 @@ public sealed class DictionaryAppService : IDictionaryAppService
var group = new DictionaryGroup var group = new DictionaryGroup
{ {
Id = Guid.NewGuid(), Id = 0,
TenantId = targetTenant, TenantId = targetTenant,
Code = normalizedCode, Code = normalizedCode,
Name = request.Name.Trim(), Name = request.Name.Trim(),
@@ -62,7 +62,7 @@ public sealed class DictionaryAppService : IDictionaryAppService
return MapGroup(group, includeItems: false); return MapGroup(group, includeItems: false);
} }
public async Task<DictionaryGroupDto> UpdateGroupAsync(Guid groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default) public async Task<DictionaryGroupDto> UpdateGroupAsync(long groupId, UpdateDictionaryGroupRequest request, CancellationToken cancellationToken = default)
{ {
var group = await RequireGroupAsync(groupId, cancellationToken); var group = await RequireGroupAsync(groupId, cancellationToken);
EnsureScopePermission(group.Scope); EnsureScopePermission(group.Scope);
@@ -77,7 +77,7 @@ public sealed class DictionaryAppService : IDictionaryAppService
return MapGroup(group, includeItems: false); return MapGroup(group, includeItems: false);
} }
public async Task DeleteGroupAsync(Guid groupId, CancellationToken cancellationToken = default) public async Task DeleteGroupAsync(long groupId, CancellationToken cancellationToken = default)
{ {
var group = await RequireGroupAsync(groupId, cancellationToken); var group = await RequireGroupAsync(groupId, cancellationToken);
EnsureScopePermission(group.Scope); EnsureScopePermission(group.Scope);
@@ -120,7 +120,7 @@ public sealed class DictionaryAppService : IDictionaryAppService
var item = new DictionaryItem var item = new DictionaryItem
{ {
Id = Guid.NewGuid(), Id = 0,
TenantId = group.TenantId, TenantId = group.TenantId,
GroupId = group.Id, GroupId = group.Id,
Key = request.Key.Trim(), Key = request.Key.Trim(),
@@ -138,7 +138,7 @@ public sealed class DictionaryAppService : IDictionaryAppService
return MapItem(item); return MapItem(item);
} }
public async Task<DictionaryItemDto> UpdateItemAsync(Guid itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default) public async Task<DictionaryItemDto> UpdateItemAsync(long itemId, UpdateDictionaryItemRequest request, CancellationToken cancellationToken = default)
{ {
var item = await RequireItemAsync(itemId, cancellationToken); var item = await RequireItemAsync(itemId, cancellationToken);
var group = await RequireGroupAsync(item.GroupId, cancellationToken); var group = await RequireGroupAsync(item.GroupId, cancellationToken);
@@ -156,7 +156,7 @@ public sealed class DictionaryAppService : IDictionaryAppService
return MapItem(item); return MapItem(item);
} }
public async Task DeleteItemAsync(Guid itemId, CancellationToken cancellationToken = default) public async Task DeleteItemAsync(long itemId, CancellationToken cancellationToken = default)
{ {
var item = await RequireItemAsync(itemId, cancellationToken); var item = await RequireItemAsync(itemId, cancellationToken);
var group = await RequireGroupAsync(item.GroupId, cancellationToken); var group = await RequireGroupAsync(item.GroupId, cancellationToken);
@@ -186,8 +186,8 @@ public sealed class DictionaryAppService : IDictionaryAppService
foreach (var code in normalizedCodes) foreach (var code in normalizedCodes)
{ {
var systemItems = await GetOrLoadCacheAsync(Guid.Empty, code, cancellationToken); var systemItems = await GetOrLoadCacheAsync(0, code, cancellationToken);
if (tenantId == Guid.Empty) if (tenantId == 0)
{ {
result[code] = systemItems; result[code] = systemItems;
continue; continue;
@@ -200,7 +200,7 @@ public sealed class DictionaryAppService : IDictionaryAppService
return result; return result;
} }
private async Task<DictionaryGroup> RequireGroupAsync(Guid groupId, CancellationToken cancellationToken) private async Task<DictionaryGroup> RequireGroupAsync(long groupId, CancellationToken cancellationToken)
{ {
var group = await _repository.FindGroupByIdAsync(groupId, cancellationToken); var group = await _repository.FindGroupByIdAsync(groupId, cancellationToken);
if (group == null) if (group == null)
@@ -211,7 +211,7 @@ public sealed class DictionaryAppService : IDictionaryAppService
return group; return group;
} }
private async Task<DictionaryItem> RequireItemAsync(Guid itemId, CancellationToken cancellationToken) private async Task<DictionaryItem> RequireItemAsync(long itemId, CancellationToken cancellationToken)
{ {
var item = await _repository.FindItemByIdAsync(itemId, cancellationToken); var item = await _repository.FindItemByIdAsync(itemId, cancellationToken);
if (item == null) if (item == null)
@@ -222,16 +222,16 @@ public sealed class DictionaryAppService : IDictionaryAppService
return item; return item;
} }
private Guid ResolveTargetTenant(DictionaryScope scope) private long ResolveTargetTenant(DictionaryScope scope)
{ {
var tenantId = _tenantProvider.GetCurrentTenantId(); var tenantId = _tenantProvider.GetCurrentTenantId();
if (scope == DictionaryScope.System) if (scope == DictionaryScope.System)
{ {
EnsurePlatformTenant(tenantId); EnsurePlatformTenant(tenantId);
return Guid.Empty; return 0;
} }
if (tenantId == Guid.Empty) if (tenantId == 0)
{ {
throw new BusinessException(ErrorCodes.BadRequest, "业务参数需指定租户"); throw new BusinessException(ErrorCodes.BadRequest, "业务参数需指定租户");
} }
@@ -241,28 +241,28 @@ public sealed class DictionaryAppService : IDictionaryAppService
private static string NormalizeCode(string code) => code.Trim().ToLowerInvariant(); private static string NormalizeCode(string code) => code.Trim().ToLowerInvariant();
private static DictionaryScope ResolveScopeForQuery(DictionaryScope? requestedScope, Guid tenantId) private static DictionaryScope ResolveScopeForQuery(DictionaryScope? requestedScope, long tenantId)
{ {
if (requestedScope.HasValue) if (requestedScope.HasValue)
{ {
return requestedScope.Value; return requestedScope.Value;
} }
return tenantId == Guid.Empty ? DictionaryScope.System : DictionaryScope.Business; return tenantId == 0 ? DictionaryScope.System : DictionaryScope.Business;
} }
private void EnsureScopePermission(DictionaryScope scope) private void EnsureScopePermission(DictionaryScope scope)
{ {
var tenantId = _tenantProvider.GetCurrentTenantId(); var tenantId = _tenantProvider.GetCurrentTenantId();
if (scope == DictionaryScope.System && tenantId != Guid.Empty) if (scope == DictionaryScope.System && tenantId != 0)
{ {
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典"); throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
} }
} }
private void EnsurePlatformTenant(Guid tenantId) private void EnsurePlatformTenant(long tenantId)
{ {
if (tenantId != Guid.Empty) if (tenantId != 0)
{ {
throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典"); throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典");
} }
@@ -279,7 +279,7 @@ public sealed class DictionaryAppService : IDictionaryAppService
// 系统参数更新需要逐租户重新合并,由调用方在下一次请求时重新加载 // 系统参数更新需要逐租户重新合并,由调用方在下一次请求时重新加载
} }
private async Task<IReadOnlyList<DictionaryItemDto>> GetOrLoadCacheAsync(Guid tenantId, string code, CancellationToken cancellationToken) private async Task<IReadOnlyList<DictionaryItemDto>> GetOrLoadCacheAsync(long tenantId, string code, CancellationToken cancellationToken)
{ {
var cached = await _cache.GetAsync(tenantId, code, cancellationToken); var cached = await _cache.GetAsync(tenantId, code, cancellationToken);
if (cached != null) if (cached != null)

View File

@@ -12,5 +12,5 @@ public interface IAdminAuthService
{ {
Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default); Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default);
Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default); Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default); Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default);
} }

View File

@@ -12,5 +12,5 @@ public interface IMiniAuthService
{ {
Task<TokenResponse> LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default); Task<TokenResponse> LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default);
Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default); Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default); Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default);
} }

View File

@@ -10,7 +10,7 @@ namespace TakeoutSaaS.Application.Identity.Abstractions;
/// </summary> /// </summary>
public interface IRefreshTokenStore public interface IRefreshTokenStore
{ {
Task<RefreshTokenDescriptor> IssueAsync(Guid userId, DateTime expiresAt, CancellationToken cancellationToken = default); Task<RefreshTokenDescriptor> IssueAsync(long userId, DateTime expiresAt, CancellationToken cancellationToken = default);
Task<RefreshTokenDescriptor?> GetAsync(string refreshToken, CancellationToken cancellationToken = default); Task<RefreshTokenDescriptor?> GetAsync(string refreshToken, CancellationToken cancellationToken = default);
Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default); Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default);
} }

View File

@@ -8,7 +8,7 @@ public sealed class CurrentUserProfile
/// <summary> /// <summary>
/// 用户 ID。 /// 用户 ID。
/// </summary> /// </summary>
public Guid UserId { get; init; } public long UserId { get; init; }
/// <summary> /// <summary>
/// 登录账号。 /// 登录账号。
@@ -23,12 +23,12 @@ public sealed class CurrentUserProfile
/// <summary> /// <summary>
/// 所属租户 ID。 /// 所属租户 ID。
/// </summary> /// </summary>
public Guid TenantId { get; init; } public long TenantId { get; init; }
/// <summary> /// <summary>
/// 所属商户 ID平台管理员为空 /// 所属商户 ID平台管理员为空
/// </summary> /// </summary>
public Guid? MerchantId { get; init; } public long? MerchantId { get; init; }
/// <summary> /// <summary>
/// 角色集合。 /// 角色集合。

View File

@@ -9,6 +9,6 @@ namespace TakeoutSaaS.Application.Identity.Models;
/// <param name="Revoked">是否已撤销</param> /// <param name="Revoked">是否已撤销</param>
public sealed record RefreshTokenDescriptor( public sealed record RefreshTokenDescriptor(
string Token, string Token,
Guid UserId, long UserId,
DateTime ExpiresAt, DateTime ExpiresAt,
bool Revoked); bool Revoked);

View File

@@ -77,7 +77,7 @@ public sealed class AdminAuthService(
/// <param name="cancellationToken">取消令牌</param> /// <param name="cancellationToken">取消令牌</param>
/// <returns>用户档案</returns> /// <returns>用户档案</returns>
/// <exception cref="BusinessException">用户不存在时抛出</exception> /// <exception cref="BusinessException">用户不存在时抛出</exception>
public async Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default) public async Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default)
{ {
var user = await userRepository.FindByIdAsync(userId, cancellationToken) var user = await userRepository.FindByIdAsync(userId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在"); ?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");

View File

@@ -44,7 +44,7 @@ public sealed class MiniAuthService(
// 3. 获取当前租户 ID多租户支持 // 3. 获取当前租户 ID多租户支持
var tenantId = tenantProvider.GetCurrentTenantId(); var tenantId = tenantProvider.GetCurrentTenantId();
if (tenantId == Guid.Empty) if (tenantId == 0)
{ {
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识"); throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
} }
@@ -95,7 +95,7 @@ public sealed class MiniAuthService(
/// <param name="cancellationToken">取消令牌</param> /// <param name="cancellationToken">取消令牌</param>
/// <returns>用户档案</returns> /// <returns>用户档案</returns>
/// <exception cref="BusinessException">用户不存在时抛出</exception> /// <exception cref="BusinessException">用户不存在时抛出</exception>
public async Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default) public async Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default)
{ {
var user = await miniUserRepository.FindByIdAsync(userId, cancellationToken) var user = await miniUserRepository.FindByIdAsync(userId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在"); ?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
@@ -113,7 +113,7 @@ public sealed class MiniAuthService(
/// <param name="tenantId">租户 ID</param> /// <param name="tenantId">租户 ID</param>
/// <param name="cancellationToken">取消令牌</param> /// <param name="cancellationToken">取消令牌</param>
/// <returns>用户实体和是否为新用户的元组</returns> /// <returns>用户实体和是否为新用户的元组</returns>
private async Task<(MiniUser user, bool isNew)> GetOrBindMiniUserAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken) private async Task<(MiniUser user, bool isNew)> GetOrBindMiniUserAsync(string openId, string? unionId, string? nickname, string? avatar, long tenantId, CancellationToken cancellationToken)
{ {
// 检查用户是否已存在 // 检查用户是否已存在
var existing = await miniUserRepository.FindByOpenIdAsync(openId, cancellationToken); var existing = await miniUserRepository.FindByOpenIdAsync(openId, cancellationToken);

View File

@@ -8,7 +8,7 @@ public sealed class OrderCreatedEvent
/// <summary> /// <summary>
/// 订单标识。 /// 订单标识。
/// </summary> /// </summary>
public Guid OrderId { get; init; } public long OrderId { get; init; }
/// <summary> /// <summary>
/// 订单编号。 /// 订单编号。
@@ -23,7 +23,7 @@ public sealed class OrderCreatedEvent
/// <summary> /// <summary>
/// 所属租户。 /// 所属租户。
/// </summary> /// </summary>
public Guid TenantId { get; init; } public long TenantId { get; init; }
/// <summary> /// <summary>
/// 创建时间UTC /// 创建时间UTC

View File

@@ -8,7 +8,7 @@ public sealed class PaymentSucceededEvent
/// <summary> /// <summary>
/// 订单标识。 /// 订单标识。
/// </summary> /// </summary>
public Guid OrderId { get; init; } public long OrderId { get; init; }
/// <summary> /// <summary>
/// 支付流水号。 /// 支付流水号。
@@ -23,7 +23,7 @@ public sealed class PaymentSucceededEvent
/// <summary> /// <summary>
/// 所属租户。 /// 所属租户。
/// </summary> /// </summary>
public Guid TenantId { get; init; } public long TenantId { get; init; }
/// <summary> /// <summary>
/// 支付时间UTC /// 支付时间UTC

View File

@@ -44,7 +44,7 @@ public sealed class VerificationCodeService(
var codeOptions = codeOptionsMonitor.CurrentValue; var codeOptions = codeOptionsMonitor.CurrentValue;
var templateCode = ResolveTemplate(request.Scene, smsOptions); var templateCode = ResolveTemplate(request.Scene, smsOptions);
var phone = NormalizePhoneNumber(request.PhoneNumber); var phone = NormalizePhoneNumber(request.PhoneNumber);
var tenantKey = tenantProvider.GetCurrentTenantId() == Guid.Empty ? "platform" : tenantProvider.GetCurrentTenantId().ToString("N"); var tenantKey = tenantProvider.GetCurrentTenantId() == 0 ? "platform" : tenantProvider.GetCurrentTenantId().ToString();
var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}"; var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}";
var cooldownKey = $"{cacheKey}:cooldown"; var cooldownKey = $"{cacheKey}:cooldown";
@@ -90,7 +90,7 @@ public sealed class VerificationCodeService(
var codeOptions = codeOptionsMonitor.CurrentValue; var codeOptions = codeOptionsMonitor.CurrentValue;
var phone = NormalizePhoneNumber(request.PhoneNumber); var phone = NormalizePhoneNumber(request.PhoneNumber);
var tenantKey = tenantProvider.GetCurrentTenantId() == Guid.Empty ? "platform" : tenantProvider.GetCurrentTenantId().ToString("N"); var tenantKey = tenantProvider.GetCurrentTenantId() == 0 ? "platform" : tenantProvider.GetCurrentTenantId().ToString();
var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}"; var cacheKey = $"{codeOptions.CachePrefix}:{tenantKey}:{request.Scene}:{phone}";
var cachedCode = await cache.GetStringAsync(cacheKey, cancellationToken).ConfigureAwait(false); var cachedCode = await cache.GetStringAsync(cacheKey, cancellationToken).ConfigureAwait(false);

View File

@@ -212,7 +212,7 @@ public sealed class FileStorageService(
private string BuildObjectKey(UploadFileType type, string extension) private string BuildObjectKey(UploadFileType type, string extension)
{ {
var tenantId = tenantProvider.GetCurrentTenantId(); var tenantId = tenantProvider.GetCurrentTenantId();
var tenantSegment = tenantId == Guid.Empty ? "platform" : tenantId.ToString("N"); var tenantSegment = tenantId == 0 ? "platform" : tenantId.ToString();
var folder = type.ToFolderName(); var folder = type.ToFolderName();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var fileName = $"{Guid.NewGuid():N}{extension}"; var fileName = $"{Guid.NewGuid():N}{extension}";

View File

@@ -8,6 +8,7 @@
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Identity.Core" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Sms\TakeoutSaaS.Module.Sms.csproj" /> <ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Sms\TakeoutSaaS.Module.Sms.csproj" />

View File

@@ -23,15 +23,15 @@ public abstract class AuditableEntityBase : EntityBase, IAuditableEntity
/// <summary> /// <summary>
/// 创建人用户标识,匿名或系统操作时为 null。 /// 创建人用户标识,匿名或系统操作时为 null。
/// </summary> /// </summary>
public Guid? CreatedBy { get; set; } public long? CreatedBy { get; set; }
/// <summary> /// <summary>
/// 最后更新人用户标识,匿名或系统操作时为 null。 /// 最后更新人用户标识,匿名或系统操作时为 null。
/// </summary> /// </summary>
public Guid? UpdatedBy { get; set; } public long? UpdatedBy { get; set; }
/// <summary> /// <summary>
/// 删除人用户标识(软删除),未删除时为 null。 /// 删除人用户标识(软删除),未删除时为 null。
/// </summary> /// </summary>
public Guid? DeletedBy { get; set; } public long? DeletedBy { get; set; }
} }

View File

@@ -8,5 +8,5 @@ public abstract class EntityBase
/// <summary> /// <summary>
/// 实体唯一标识。 /// 实体唯一标识。
/// </summary> /// </summary>
public Guid Id { get; set; } public long Id { get; set; }
} }

View File

@@ -23,15 +23,15 @@ public interface IAuditableEntity : ISoftDeleteEntity
/// <summary> /// <summary>
/// 创建人用户标识,匿名或系统操作时为 null。 /// 创建人用户标识,匿名或系统操作时为 null。
/// </summary> /// </summary>
Guid? CreatedBy { get; set; } long? CreatedBy { get; set; }
/// <summary> /// <summary>
/// 最后更新人用户标识,匿名或系统操作时为 null。 /// 最后更新人用户标识,匿名或系统操作时为 null。
/// </summary> /// </summary>
Guid? UpdatedBy { get; set; } long? UpdatedBy { get; set; }
/// <summary> /// <summary>
/// 删除人用户标识(软删除),未删除时为 null。 /// 删除人用户标识(软删除),未删除时为 null。
/// </summary> /// </summary>
Guid? DeletedBy { get; set; } long? DeletedBy { get; set; }
} }

View File

@@ -8,5 +8,5 @@ public interface IMultiTenantEntity
/// <summary> /// <summary>
/// 所属租户 ID。 /// 所属租户 ID。
/// </summary> /// </summary>
Guid TenantId { get; set; } long TenantId { get; set; }
} }

View File

@@ -8,5 +8,5 @@ public abstract class MultiTenantEntityBase : AuditableEntityBase, IMultiTenantE
/// <summary> /// <summary>
/// 所属租户 ID。 /// 所属租户 ID。
/// </summary> /// </summary>
public Guid TenantId { get; set; } public long TenantId { get; set; }
} }

View File

@@ -0,0 +1,13 @@
namespace TakeoutSaaS.Shared.Abstractions.Ids;
/// <summary>
/// 雪花 ID 生成器接口。
/// </summary>
public interface IIdGenerator
{
/// <summary>
/// 生成下一个唯一长整型 ID。
/// </summary>
/// <returns>雪花 ID。</returns>
long NextId();
}

View File

@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Shared.Abstractions.Ids;
/// <summary>
/// 雪花 ID 生成器配置。
/// </summary>
public sealed class IdGeneratorOptions
{
/// <summary>
/// 配置节名称。
/// </summary>
public const string SectionName = "IdGenerator";
/// <summary>
/// 工作节点标识0-31。
/// </summary>
[Range(0, 31)]
public int WorkerId { get; set; }
/// <summary>
/// 机房标识0-31。
/// </summary>
[Range(0, 31)]
public int DatacenterId { get; set; }
}

View File

@@ -99,6 +99,65 @@ public sealed record ApiResponse<T>
return TraceContext.TraceId; return TraceContext.TraceId;
} }
return Activity.Current?.Id ?? Guid.NewGuid().ToString("N"); if (!string.IsNullOrWhiteSpace(TraceContext.TraceId))
{
return TraceContext.TraceId;
}
if (Activity.Current?.Id is { } id && !string.IsNullOrWhiteSpace(id))
{
return id;
}
return IdFallbackGenerator.Instance.NextId().ToString();
}
}
internal sealed class IdFallbackGenerator
{
private static readonly Lazy<IdFallbackGenerator> Lazy = new(() => new IdFallbackGenerator());
public static IdFallbackGenerator Instance => Lazy.Value;
private readonly object _sync = new();
private long _lastTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private long _sequence;
private IdFallbackGenerator()
{
}
public long NextId()
{
lock (_sync)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (timestamp == _lastTimestamp)
{
_sequence = (_sequence + 1) & 4095;
if (_sequence == 0)
{
timestamp = WaitNextMillis(_lastTimestamp);
}
}
else
{
_sequence = 0;
}
_lastTimestamp = timestamp;
return ((timestamp - 1577836800000L) << 22) | _sequence;
}
}
private static long WaitNextMillis(long lastTimestamp)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
while (timestamp <= lastTimestamp)
{
Thread.SpinWait(100);
timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
return timestamp;
} }
} }

View File

@@ -8,7 +8,7 @@ public interface ICurrentUserAccessor
/// <summary> /// <summary>
/// 当前用户 ID未登录时为 Guid.Empty。 /// 当前用户 ID未登录时为 Guid.Empty。
/// </summary> /// </summary>
Guid UserId { get; } long UserId { get; }
/// <summary> /// <summary>
/// 是否已登录。 /// 是否已登录。

View File

@@ -0,0 +1,52 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace TakeoutSaaS.Shared.Abstractions.Serialization;
/// <summary>
/// 将 long 类型的雪花 ID 以字符串形式序列化/反序列化,避免前端精度丢失。
/// </summary>
public sealed class SnowflakeIdJsonConverter : JsonConverter<long>
{
/// <inheritdoc />
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.Number => reader.GetInt64(),
JsonTokenType.String when long.TryParse(reader.GetString(), out var value) => value,
JsonTokenType.Null => 0,
_ => throw new JsonException("无法解析雪花 ID")
};
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
{
writer.WriteStringValue(value == 0 ? "0" : value.ToString());
}
}
/// <summary>
/// 可空雪花 ID 转换器。
/// </summary>
public sealed class NullableSnowflakeIdJsonConverter : JsonConverter<long?>
{
/// <inheritdoc />
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.Number => reader.GetInt64(),
JsonTokenType.String when long.TryParse(reader.GetString(), out var value) => value,
JsonTokenType.Null => null,
_ => throw new JsonException("无法解析雪花 ID")
};
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.HasValue ? value.Value.ToString() : null);
}
}

View File

@@ -8,5 +8,5 @@ public interface ITenantProvider
/// <summary> /// <summary>
/// 获取当前租户 ID未解析时返回 Guid.Empty。 /// 获取当前租户 ID未解析时返回 Guid.Empty。
/// </summary> /// </summary>
Guid GetCurrentTenantId(); long GetCurrentTenantId();
} }

View File

@@ -8,7 +8,7 @@ public sealed class TenantContext
/// <summary> /// <summary>
/// 未解析到租户时的默认上下文。 /// 未解析到租户时的默认上下文。
/// </summary> /// </summary>
public static TenantContext Empty { get; } = new(Guid.Empty, null, "unresolved"); public static TenantContext Empty { get; } = new(0, null, "unresolved");
/// <summary> /// <summary>
/// 初始化租户上下文。 /// 初始化租户上下文。
@@ -16,7 +16,7 @@ public sealed class TenantContext
/// <param name="tenantId">租户 ID</param> /// <param name="tenantId">租户 ID</param>
/// <param name="tenantCode">租户编码(可选)</param> /// <param name="tenantCode">租户编码(可选)</param>
/// <param name="source">解析来源</param> /// <param name="source">解析来源</param>
public TenantContext(Guid tenantId, string? tenantCode, string source) public TenantContext(long tenantId, string? tenantCode, string source)
{ {
TenantId = tenantId; TenantId = tenantId;
TenantCode = tenantCode; TenantCode = tenantCode;
@@ -26,7 +26,7 @@ public sealed class TenantContext
/// <summary> /// <summary>
/// 当前租户 ID未解析时为 Guid.Empty。 /// 当前租户 ID未解析时为 Guid.Empty。
/// </summary> /// </summary>
public Guid TenantId { get; } public long TenantId { get; }
/// <summary> /// <summary>
/// 当前租户编码(例如子域名或业务编码),可为空。 /// 当前租户编码(例如子域名或业务编码),可为空。
@@ -41,5 +41,5 @@ public sealed class TenantContext
/// <summary> /// <summary>
/// 是否已成功解析到租户。 /// 是否已成功解析到租户。
/// </summary> /// </summary>
public bool IsResolved => TenantId != Guid.Empty; public bool IsResolved => TenantId != 0;
} }

View File

@@ -0,0 +1,111 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Threading;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Shared.Kernel.Ids;
/// <summary>
/// 基于雪花算法的长整型 ID 生成器。
/// </summary>
public sealed class SnowflakeIdGenerator : IIdGenerator
{
private const long Twepoch = 1577836800000L; // 2020-01-01 UTC
private const int WorkerIdBits = 5;
private const int DatacenterIdBits = 5;
private const int SequenceBits = 12;
private const long MaxWorkerId = -1L ^ (-1L << WorkerIdBits);
private const long MaxDatacenterId = -1L ^ (-1L << DatacenterIdBits);
private const int WorkerIdShift = SequenceBits;
private const int DatacenterIdShift = SequenceBits + WorkerIdBits;
private const int TimestampLeftShift = SequenceBits + WorkerIdBits + DatacenterIdBits;
private const long SequenceMask = -1L ^ (-1L << SequenceBits);
private readonly long _workerId;
private readonly long _datacenterId;
private long _lastTimestamp = -1L;
private long _sequence;
private readonly object _syncRoot = new();
/// <summary>
/// 初始化生成器。
/// </summary>
/// <param name="workerId">工作节点 ID。</param>
/// <param name="datacenterId">机房 ID。</param>
public SnowflakeIdGenerator(long workerId = 0, long datacenterId = 0)
{
_workerId = Normalize(workerId, MaxWorkerId, nameof(workerId));
_datacenterId = Normalize(datacenterId, MaxDatacenterId, nameof(datacenterId));
_sequence = RandomNumberGenerator.GetInt32(0, (int)SequenceMask);
}
/// <inheritdoc />
public long NextId()
{
lock (_syncRoot)
{
var timestamp = CurrentTimeMillis();
if (timestamp < _lastTimestamp)
{
// 时钟回拨时等待到下一毫秒。
var wait = _lastTimestamp - timestamp;
Thread.Sleep(TimeSpan.FromMilliseconds(wait));
timestamp = CurrentTimeMillis();
if (timestamp < _lastTimestamp)
{
throw new InvalidOperationException($"系统时钟回拨 {_lastTimestamp - timestamp} 毫秒,无法生成 ID。");
}
}
if (_lastTimestamp == timestamp)
{
_sequence = (_sequence + 1) & SequenceMask;
if (_sequence == 0)
{
timestamp = WaitNextMillis(_lastTimestamp);
}
}
else
{
_sequence = 0;
}
_lastTimestamp = timestamp;
var id = ((timestamp - Twepoch) << TimestampLeftShift)
| (_datacenterId << DatacenterIdShift)
| (_workerId << WorkerIdShift)
| _sequence;
Debug.Assert(id > 0);
return id;
}
}
private static long WaitNextMillis(long lastTimestamp)
{
var timestamp = CurrentTimeMillis();
while (timestamp <= lastTimestamp)
{
Thread.SpinWait(50);
timestamp = CurrentTimeMillis();
}
return timestamp;
}
private static long CurrentTimeMillis() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private static long Normalize(long value, long max, string name)
{
if (value < 0 || value > max)
{
throw new ArgumentOutOfRangeException(name, value, $"取值范围 0~{max}");
}
return value;
}
}

View File

@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using TakeoutSaaS.Shared.Abstractions.Diagnostics; using TakeoutSaaS.Shared.Abstractions.Diagnostics;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Shared.Web.Middleware; namespace TakeoutSaaS.Shared.Web.Middleware;
@@ -17,11 +18,13 @@ public sealed class CorrelationIdMiddleware
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly ILogger<CorrelationIdMiddleware> _logger; private readonly ILogger<CorrelationIdMiddleware> _logger;
private readonly IIdGenerator _idGenerator;
public CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger) public CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger, IIdGenerator idGenerator)
{ {
_next = next; _next = next;
_logger = logger; _logger = logger;
_idGenerator = idGenerator;
} }
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
@@ -52,7 +55,7 @@ public sealed class CorrelationIdMiddleware
} }
} }
private static string ResolveTraceId(HttpContext context) private string ResolveTraceId(HttpContext context)
{ {
if (TryGetHeader(context, TraceHeader, out var traceId)) if (TryGetHeader(context, TraceHeader, out var traceId))
{ {
@@ -64,7 +67,7 @@ public sealed class CorrelationIdMiddleware
return requestId; return requestId;
} }
return Guid.NewGuid().ToString("N"); return _idGenerator.NextId().ToString();
} }
private static bool TryGetHeader(HttpContext context, string headerName, out string value) private static bool TryGetHeader(HttpContext context, string headerName, out string value)

View File

@@ -9,20 +9,20 @@ namespace TakeoutSaaS.Shared.Web.Security;
public static class ClaimsPrincipalExtensions public static class ClaimsPrincipalExtensions
{ {
/// <summary> /// <summary>
/// 获取当前用户 Id不存在时返回 Guid.Empty /// 获取当前用户 Id不存在时返回 0
/// </summary> /// </summary>
public static Guid GetUserId(this ClaimsPrincipal? principal) public static long GetUserId(this ClaimsPrincipal? principal)
{ {
if (principal == null) if (principal == null)
{ {
return Guid.Empty; return 0;
} }
var identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier) var identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier)
?? principal.FindFirstValue("sub"); ?? principal.FindFirstValue("sub");
return Guid.TryParse(identifier, out var userId) return long.TryParse(identifier, out var userId)
? userId ? userId
: Guid.Empty; : 0;
} }
} }

View File

@@ -20,23 +20,23 @@ public sealed class HttpContextCurrentUserAccessor : ICurrentUserAccessor
} }
/// <inheritdoc /> /// <inheritdoc />
public Guid UserId public long UserId
{ {
get get
{ {
var principal = _httpContextAccessor.HttpContext?.User; var principal = _httpContextAccessor.HttpContext?.User;
if (principal == null || !principal.Identity?.IsAuthenticated == true) if (principal == null || !principal.Identity?.IsAuthenticated == true)
{ {
return Guid.Empty; return 0;
} }
var identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier) var identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier)
?? principal.FindFirstValue("sub"); ?? principal.FindFirstValue("sub");
return Guid.TryParse(identifier, out var id) ? id : Guid.Empty; return long.TryParse(identifier, out var id) ? id : 0;
} }
} }
/// <inheritdoc /> /// <inheritdoc />
public bool IsAuthenticated => UserId != Guid.Empty; public bool IsAuthenticated => UserId != 0;
} }

View File

@@ -11,7 +11,7 @@ public sealed class MetricAlertRule : MultiTenantEntityBase
/// <summary> /// <summary>
/// 关联指标。 /// 关联指标。
/// </summary> /// </summary>
public Guid MetricDefinitionId { get; set; } public long MetricDefinitionId { get; set; }
/// <summary> /// <summary>
/// 触发条件 JSON。 /// 触发条件 JSON。

View File

@@ -10,7 +10,7 @@ public sealed class MetricSnapshot : MultiTenantEntityBase
/// <summary> /// <summary>
/// 指标定义 ID。 /// 指标定义 ID。
/// </summary> /// </summary>
public Guid MetricDefinitionId { get; set; } public long MetricDefinitionId { get; set; }
/// <summary> /// <summary>
/// 维度键JSON /// 维度键JSON

View File

@@ -11,7 +11,7 @@ public sealed class Coupon : MultiTenantEntityBase
/// <summary> /// <summary>
/// 模板标识。 /// 模板标识。
/// </summary> /// </summary>
public Guid CouponTemplateId { get; set; } public long CouponTemplateId { get; set; }
/// <summary> /// <summary>
/// 券码或序列号。 /// 券码或序列号。
@@ -21,12 +21,12 @@ public sealed class Coupon : MultiTenantEntityBase
/// <summary> /// <summary>
/// 归属用户。 /// 归属用户。
/// </summary> /// </summary>
public Guid UserId { get; set; } public long UserId { get; set; }
/// <summary> /// <summary>
/// 订单 ID已使用时记录 /// 订单 ID已使用时记录
/// </summary> /// </summary>
public Guid? OrderId { get; set; } public long? OrderId { get; set; }
/// <summary> /// <summary>
/// 状态。 /// 状态。

View File

@@ -11,7 +11,7 @@ public sealed class ChatMessage : MultiTenantEntityBase
/// <summary> /// <summary>
/// 会话标识。 /// 会话标识。
/// </summary> /// </summary>
public Guid ChatSessionId { get; set; } public long ChatSessionId { get; set; }
/// <summary> /// <summary>
/// 发送方类型。 /// 发送方类型。
@@ -21,7 +21,7 @@ public sealed class ChatMessage : MultiTenantEntityBase
/// <summary> /// <summary>
/// 发送方用户 ID。 /// 发送方用户 ID。
/// </summary> /// </summary>
public Guid? SenderUserId { get; set; } public long? SenderUserId { get; set; }
/// <summary> /// <summary>
/// 消息内容。 /// 消息内容。

View File

@@ -16,17 +16,17 @@ public sealed class ChatSession : MultiTenantEntityBase
/// <summary> /// <summary>
/// 顾客用户 ID。 /// 顾客用户 ID。
/// </summary> /// </summary>
public Guid CustomerUserId { get; set; } public long CustomerUserId { get; set; }
/// <summary> /// <summary>
/// 当前客服员工 ID。 /// 当前客服员工 ID。
/// </summary> /// </summary>
public Guid? AgentUserId { get; set; } public long? AgentUserId { get; set; }
/// <summary> /// <summary>
/// 所属门店(可空为平台)。 /// 所属门店(可空为平台)。
/// </summary> /// </summary>
public Guid? StoreId { get; set; } public long? StoreId { get; set; }
/// <summary> /// <summary>
/// 会话状态。 /// 会话状态。

View File

@@ -16,12 +16,12 @@ public sealed class SupportTicket : MultiTenantEntityBase
/// <summary> /// <summary>
/// 客户用户 ID。 /// 客户用户 ID。
/// </summary> /// </summary>
public Guid CustomerUserId { get; set; } public long CustomerUserId { get; set; }
/// <summary> /// <summary>
/// 关联订单(如有)。 /// 关联订单(如有)。
/// </summary> /// </summary>
public Guid? OrderId { get; set; } public long? OrderId { get; set; }
/// <summary> /// <summary>
/// 工单主题。 /// 工单主题。
@@ -46,7 +46,7 @@ public sealed class SupportTicket : MultiTenantEntityBase
/// <summary> /// <summary>
/// 指派的客服。 /// 指派的客服。
/// </summary> /// </summary>
public Guid? AssignedAgentId { get; set; } public long? AssignedAgentId { get; set; }
/// <summary> /// <summary>
/// 关闭时间。 /// 关闭时间。

View File

@@ -10,12 +10,12 @@ public sealed class TicketComment : MultiTenantEntityBase
/// <summary> /// <summary>
/// 工单标识。 /// 工单标识。
/// </summary> /// </summary>
public Guid SupportTicketId { get; set; } public long SupportTicketId { get; set; }
/// <summary> /// <summary>
/// 评论人 ID。 /// 评论人 ID。
/// </summary> /// </summary>
public Guid? AuthorUserId { get; set; } public long? AuthorUserId { get; set; }
/// <summary> /// <summary>
/// 评论内容。 /// 评论内容。

View File

@@ -11,7 +11,7 @@ public sealed class DeliveryEvent : MultiTenantEntityBase
/// <summary> /// <summary>
/// 配送单标识。 /// 配送单标识。
/// </summary> /// </summary>
public Guid DeliveryOrderId { get; set; } public long DeliveryOrderId { get; set; }
/// <summary> /// <summary>
/// 事件类型。 /// 事件类型。

View File

@@ -8,7 +8,7 @@ namespace TakeoutSaaS.Domain.Deliveries.Entities;
/// </summary> /// </summary>
public sealed class DeliveryOrder : MultiTenantEntityBase public sealed class DeliveryOrder : MultiTenantEntityBase
{ {
public Guid OrderId { get; set; } public long OrderId { get; set; }
/// <summary> /// <summary>
/// 配送服务商。 /// 配送服务商。

View File

@@ -0,0 +1,42 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Domain.Deliveries.Entities;
namespace TakeoutSaaS.Domain.Deliveries.Repositories;
/// <summary>
/// 配送聚合仓储契约。
/// </summary>
public interface IDeliveryRepository
{
/// <summary>
/// 依据标识获取配送单。
/// </summary>
Task<DeliveryOrder?> FindByIdAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 依据订单标识获取配送单。
/// </summary>
Task<DeliveryOrder?> FindByOrderIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取配送事件轨迹。
/// </summary>
Task<IReadOnlyList<DeliveryEvent>> GetEventsAsync(long deliveryOrderId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增配送单。
/// </summary>
Task AddDeliveryOrderAsync(DeliveryOrder deliveryOrder, CancellationToken cancellationToken = default);
/// <summary>
/// 新增配送事件。
/// </summary>
Task AddEventAsync(DeliveryEvent deliveryEvent, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -10,7 +10,7 @@ public sealed class DictionaryItem : MultiTenantEntityBase
/// <summary> /// <summary>
/// 关联分组 ID。 /// 关联分组 ID。
/// </summary> /// </summary>
public Guid GroupId { get; set; } public long GroupId { get; set; }
/// <summary> /// <summary>
/// 字典项键。 /// 字典项键。

View File

@@ -14,7 +14,7 @@ public interface IDictionaryRepository
/// <summary> /// <summary>
/// 依据 ID 获取分组。 /// 依据 ID 获取分组。
/// </summary> /// </summary>
Task<DictionaryGroup?> FindGroupByIdAsync(Guid id, CancellationToken cancellationToken = default); Task<DictionaryGroup?> FindGroupByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 依据编码获取分组。 /// 依据编码获取分组。
@@ -39,17 +39,17 @@ public interface IDictionaryRepository
/// <summary> /// <summary>
/// 依据 ID 获取字典项。 /// 依据 ID 获取字典项。
/// </summary> /// </summary>
Task<DictionaryItem?> FindItemByIdAsync(Guid id, CancellationToken cancellationToken = default); Task<DictionaryItem?> FindItemByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 获取某分组下的所有字典项。 /// 获取某分组下的所有字典项。
/// </summary> /// </summary>
Task<IReadOnlyList<DictionaryItem>> GetItemsByGroupIdAsync(Guid groupId, CancellationToken cancellationToken = default); Task<IReadOnlyList<DictionaryItem>> GetItemsByGroupIdAsync(long groupId, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 按分组编码集合获取字典项(可包含系统参数)。 /// 按分组编码集合获取字典项(可包含系统参数)。
/// </summary> /// </summary>
Task<IReadOnlyList<DictionaryItem>> GetItemsByCodesAsync(IEnumerable<string> codes, Guid tenantId, bool includeSystem, CancellationToken cancellationToken = default); Task<IReadOnlyList<DictionaryItem>> GetItemsByCodesAsync(IEnumerable<string> codes, long tenantId, bool includeSystem, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 新增字典项。 /// 新增字典项。

View File

@@ -11,17 +11,17 @@ public sealed class AffiliateOrder : MultiTenantEntityBase
/// <summary> /// <summary>
/// 推广人标识。 /// 推广人标识。
/// </summary> /// </summary>
public Guid AffiliatePartnerId { get; set; } public long AffiliatePartnerId { get; set; }
/// <summary> /// <summary>
/// 关联订单。 /// 关联订单。
/// </summary> /// </summary>
public Guid OrderId { get; set; } public long OrderId { get; set; }
/// <summary> /// <summary>
/// 用户 ID。 /// 用户 ID。
/// </summary> /// </summary>
public Guid BuyerUserId { get; set; } public long BuyerUserId { get; set; }
/// <summary> /// <summary>
/// 订单金额。 /// 订单金额。

View File

@@ -11,7 +11,7 @@ public sealed class AffiliatePartner : MultiTenantEntityBase
/// <summary> /// <summary>
/// 用户 ID如绑定平台账号 /// 用户 ID如绑定平台账号
/// </summary> /// </summary>
public Guid? UserId { get; set; } public long? UserId { get; set; }
/// <summary> /// <summary>
/// 昵称或渠道名称。 /// 昵称或渠道名称。

View File

@@ -11,7 +11,7 @@ public sealed class AffiliatePayout : MultiTenantEntityBase
/// <summary> /// <summary>
/// 合作伙伴标识。 /// 合作伙伴标识。
/// </summary> /// </summary>
public Guid AffiliatePartnerId { get; set; } public long AffiliatePartnerId { get; set; }
/// <summary> /// <summary>
/// 结算周期描述。 /// 结算周期描述。

View File

@@ -10,12 +10,12 @@ public sealed class CheckInRecord : MultiTenantEntityBase
/// <summary> /// <summary>
/// 活动标识。 /// 活动标识。
/// </summary> /// </summary>
public Guid CheckInCampaignId { get; set; } public long CheckInCampaignId { get; set; }
/// <summary> /// <summary>
/// 用户标识。 /// 用户标识。
/// </summary> /// </summary>
public Guid UserId { get; set; } public long UserId { get; set; }
/// <summary> /// <summary>
/// 签到日期(本地)。 /// 签到日期(本地)。

View File

@@ -10,12 +10,12 @@ public sealed class CommunityComment : MultiTenantEntityBase
/// <summary> /// <summary>
/// 动态标识。 /// 动态标识。
/// </summary> /// </summary>
public Guid PostId { get; set; } public long PostId { get; set; }
/// <summary> /// <summary>
/// 评论人。 /// 评论人。
/// </summary> /// </summary>
public Guid AuthorUserId { get; set; } public long AuthorUserId { get; set; }
/// <summary> /// <summary>
/// 评论内容。 /// 评论内容。
@@ -25,7 +25,7 @@ public sealed class CommunityComment : MultiTenantEntityBase
/// <summary> /// <summary>
/// 父级评论 ID。 /// 父级评论 ID。
/// </summary> /// </summary>
public Guid? ParentId { get; set; } public long? ParentId { get; set; }
/// <summary> /// <summary>
/// 状态。 /// 状态。

View File

@@ -11,7 +11,7 @@ public sealed class CommunityPost : MultiTenantEntityBase
/// <summary> /// <summary>
/// 作者用户 ID。 /// 作者用户 ID。
/// </summary> /// </summary>
public Guid AuthorUserId { get; set; } public long AuthorUserId { get; set; }
/// <summary> /// <summary>
/// 标题。 /// 标题。

View File

@@ -11,12 +11,12 @@ public sealed class CommunityReaction : MultiTenantEntityBase
/// <summary> /// <summary>
/// 动态 ID。 /// 动态 ID。
/// </summary> /// </summary>
public Guid PostId { get; set; } public long PostId { get; set; }
/// <summary> /// <summary>
/// 用户 ID。 /// 用户 ID。
/// </summary> /// </summary>
public Guid UserId { get; set; } public long UserId { get; set; }
/// <summary> /// <summary>
/// 反应类型。 /// 反应类型。

View File

@@ -11,12 +11,12 @@ public sealed class GroupOrder : MultiTenantEntityBase
/// <summary> /// <summary>
/// 门店标识。 /// 门店标识。
/// </summary> /// </summary>
public Guid StoreId { get; set; } public long StoreId { get; set; }
/// <summary> /// <summary>
/// 关联商品或套餐。 /// 关联商品或套餐。
/// </summary> /// </summary>
public Guid ProductId { get; set; } public long ProductId { get; set; }
/// <summary> /// <summary>
/// 拼单编号。 /// 拼单编号。
@@ -26,7 +26,7 @@ public sealed class GroupOrder : MultiTenantEntityBase
/// <summary> /// <summary>
/// 团长用户 ID。 /// 团长用户 ID。
/// </summary> /// </summary>
public Guid LeaderUserId { get; set; } public long LeaderUserId { get; set; }
/// <summary> /// <summary>
/// 成团需要的人数。 /// 成团需要的人数。

View File

@@ -11,17 +11,17 @@ public sealed class GroupParticipant : MultiTenantEntityBase
/// <summary> /// <summary>
/// 拼单活动标识。 /// 拼单活动标识。
/// </summary> /// </summary>
public Guid GroupOrderId { get; set; } public long GroupOrderId { get; set; }
/// <summary> /// <summary>
/// 对应订单标识。 /// 对应订单标识。
/// </summary> /// </summary>
public Guid OrderId { get; set; } public long OrderId { get; set; }
/// <summary> /// <summary>
/// 用户标识。 /// 用户标识。
/// </summary> /// </summary>
public Guid UserId { get; set; } public long UserId { get; set; }
/// <summary> /// <summary>
/// 参与状态。 /// 参与状态。

View File

@@ -25,7 +25,7 @@ public sealed class IdentityUser : MultiTenantEntityBase
/// <summary> /// <summary>
/// 所属商户(平台管理员为空)。 /// 所属商户(平台管理员为空)。
/// </summary> /// </summary>
public Guid? MerchantId { get; set; } public long? MerchantId { get; set; }
/// <summary> /// <summary>
/// 角色集合。 /// 角色集合。

View File

@@ -18,5 +18,5 @@ public interface IIdentityUserRepository
/// <summary> /// <summary>
/// 根据 ID 获取后台用户。 /// 根据 ID 获取后台用户。
/// </summary> /// </summary>
Task<IdentityUser?> FindByIdAsync(Guid userId, CancellationToken cancellationToken = default); Task<IdentityUser?> FindByIdAsync(long userId, CancellationToken cancellationToken = default);
} }

View File

@@ -21,7 +21,7 @@ public interface IMiniUserRepository
/// <param name="id">用户 ID</param> /// <param name="id">用户 ID</param>
/// <param name="cancellationToken">取消令牌</param> /// <param name="cancellationToken">取消令牌</param>
/// <returns>小程序用户,如果不存在则返回 null</returns> /// <returns>小程序用户,如果不存在则返回 null</returns>
Task<MiniUser?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default); Task<MiniUser?> FindByIdAsync(long id, CancellationToken cancellationToken = default);
/// <summary> /// <summary>
/// 创建或更新小程序用户(如果 OpenId 已存在则更新,否则创建)。 /// 创建或更新小程序用户(如果 OpenId 已存在则更新,否则创建)。
@@ -33,5 +33,5 @@ public interface IMiniUserRepository
/// <param name="tenantId">租户 ID</param> /// <param name="tenantId">租户 ID</param>
/// <param name="cancellationToken">取消令牌</param> /// <param name="cancellationToken">取消令牌</param>
/// <returns>创建或更新后的小程序用户</returns> /// <returns>创建或更新后的小程序用户</returns>
Task<MiniUser> CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken = default); Task<MiniUser> CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, long tenantId, CancellationToken cancellationToken = default);
} }

View File

@@ -11,7 +11,7 @@ public sealed class InventoryAdjustment : MultiTenantEntityBase
/// <summary> /// <summary>
/// 对应的库存记录标识。 /// 对应的库存记录标识。
/// </summary> /// </summary>
public Guid InventoryItemId { get; set; } public long InventoryItemId { get; set; }
/// <summary> /// <summary>
/// 调整类型。 /// 调整类型。
@@ -31,7 +31,7 @@ public sealed class InventoryAdjustment : MultiTenantEntityBase
/// <summary> /// <summary>
/// 操作人标识。 /// 操作人标识。
/// </summary> /// </summary>
public Guid? OperatorId { get; set; } public long? OperatorId { get; set; }
/// <summary> /// <summary>
/// 发生时间。 /// 发生时间。

View File

@@ -10,12 +10,12 @@ public sealed class InventoryBatch : MultiTenantEntityBase
/// <summary> /// <summary>
/// 门店标识。 /// 门店标识。
/// </summary> /// </summary>
public Guid StoreId { get; set; } public long StoreId { get; set; }
/// <summary> /// <summary>
/// SKU 标识。 /// SKU 标识。
/// </summary> /// </summary>
public Guid ProductSkuId { get; set; } public long ProductSkuId { get; set; }
/// <summary> /// <summary>
/// 批次编号。 /// 批次编号。

View File

@@ -10,12 +10,12 @@ public sealed class InventoryItem : MultiTenantEntityBase
/// <summary> /// <summary>
/// 门店标识。 /// 门店标识。
/// </summary> /// </summary>
public Guid StoreId { get; set; } public long StoreId { get; set; }
/// <summary> /// <summary>
/// SKU 标识。 /// SKU 标识。
/// </summary> /// </summary>
public Guid ProductSkuId { get; set; } public long ProductSkuId { get; set; }
/// <summary> /// <summary>
/// 批次编号,可为空表示混批。 /// 批次编号,可为空表示混批。

View File

@@ -10,7 +10,7 @@ public sealed class MemberGrowthLog : MultiTenantEntityBase
/// <summary> /// <summary>
/// 会员标识。 /// 会员标识。
/// </summary> /// </summary>
public Guid MemberId { get; set; } public long MemberId { get; set; }
/// <summary> /// <summary>
/// 变动数量。 /// 变动数量。

View File

@@ -11,7 +11,7 @@ public sealed class MemberPointLedger : MultiTenantEntityBase
/// <summary> /// <summary>
/// 会员标识。 /// 会员标识。
/// </summary> /// </summary>
public Guid MemberId { get; set; } public long MemberId { get; set; }
/// <summary> /// <summary>
/// 变动数量,可为负值。 /// 变动数量,可为负值。
@@ -31,7 +31,7 @@ public sealed class MemberPointLedger : MultiTenantEntityBase
/// <summary> /// <summary>
/// 来源 ID订单、活动等 /// 来源 ID订单、活动等
/// </summary> /// </summary>
public Guid? SourceId { get; set; } public long? SourceId { get; set; }
/// <summary> /// <summary>
/// 发生时间。 /// 发生时间。

View File

@@ -11,7 +11,7 @@ public sealed class MemberProfile : MultiTenantEntityBase
/// <summary> /// <summary>
/// 用户标识。 /// 用户标识。
/// </summary> /// </summary>
public Guid UserId { get; set; } public long UserId { get; set; }
/// <summary> /// <summary>
/// 手机号。 /// 手机号。
@@ -31,7 +31,7 @@ public sealed class MemberProfile : MultiTenantEntityBase
/// <summary> /// <summary>
/// 当前会员等级 ID。 /// 当前会员等级 ID。
/// </summary> /// </summary>
public Guid? MemberTierId { get; set; } public long? MemberTierId { get; set; }
/// <summary> /// <summary>
/// 会员状态。 /// 会员状态。

View File

@@ -11,7 +11,7 @@ public sealed class MerchantContract : MultiTenantEntityBase
/// <summary> /// <summary>
/// 所属商户标识。 /// 所属商户标识。
/// </summary> /// </summary>
public Guid MerchantId { get; set; } public long MerchantId { get; set; }
/// <summary> /// <summary>
/// 合同编号。 /// 合同编号。

View File

@@ -11,7 +11,7 @@ public sealed class MerchantDocument : MultiTenantEntityBase
/// <summary> /// <summary>
/// 所属商户标识。 /// 所属商户标识。
/// </summary> /// </summary>
public Guid MerchantId { get; set; } public long MerchantId { get; set; }
/// <summary> /// <summary>
/// 证照类型。 /// 证照类型。

View File

@@ -11,12 +11,12 @@ public sealed class MerchantStaff : MultiTenantEntityBase
/// <summary> /// <summary>
/// 所属商户标识。 /// 所属商户标识。
/// </summary> /// </summary>
public Guid MerchantId { get; set; } public long MerchantId { get; set; }
/// <summary> /// <summary>
/// 可选的关联门店 ID。 /// 可选的关联门店 ID。
/// </summary> /// </summary>
public Guid? StoreId { get; set; } public long? StoreId { get; set; }
/// <summary> /// <summary>
/// 员工姓名。 /// 员工姓名。
@@ -36,7 +36,7 @@ public sealed class MerchantStaff : MultiTenantEntityBase
/// <summary> /// <summary>
/// 登录账号 ID指向统一身份体系 /// 登录账号 ID指向统一身份体系
/// </summary> /// </summary>
public Guid? IdentityUserId { get; set; } public long? IdentityUserId { get; set; }
/// <summary> /// <summary>
/// 员工角色类型。 /// 员工角色类型。

View File

@@ -0,0 +1,63 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Domain.Merchants.Entities;
using TakeoutSaaS.Domain.Merchants.Enums;
namespace TakeoutSaaS.Domain.Merchants.Repositories;
/// <summary>
/// 商户聚合仓储契约,提供基础 CRUD 与查询能力。
/// </summary>
public interface IMerchantRepository
{
/// <summary>
/// 依据标识获取商户。
/// </summary>
Task<Merchant?> FindByIdAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 按状态筛选商户列表。
/// </summary>
Task<IReadOnlyList<Merchant>> SearchAsync(long tenantId, MerchantStatus? status, CancellationToken cancellationToken = default);
/// <summary>
/// 获取指定商户的员工列表。
/// </summary>
Task<IReadOnlyList<MerchantStaff>> GetStaffAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取指定商户的合同列表。
/// </summary>
Task<IReadOnlyList<MerchantContract>> GetContractsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取指定商户的资质文件列表。
/// </summary>
Task<IReadOnlyList<MerchantDocument>> GetDocumentsAsync(long merchantId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增商户主体。
/// </summary>
Task AddMerchantAsync(Merchant merchant, CancellationToken cancellationToken = default);
/// <summary>
/// 新增商户员工。
/// </summary>
Task AddStaffAsync(MerchantStaff staff, CancellationToken cancellationToken = default);
/// <summary>
/// 新增商户合同。
/// </summary>
Task AddContractAsync(MerchantContract contract, CancellationToken cancellationToken = default);
/// <summary>
/// 新增商户资质文件。
/// </summary>
Task AddDocumentAsync(MerchantDocument document, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -10,7 +10,7 @@ public sealed class MapLocation : MultiTenantEntityBase
/// <summary> /// <summary>
/// 关联门店 ID可空表示独立 POI。 /// 关联门店 ID可空表示独立 POI。
/// </summary> /// </summary>
public Guid? StoreId { get; set; } public long? StoreId { get; set; }
/// <summary> /// <summary>
/// 名称。 /// 名称。

View File

@@ -11,12 +11,12 @@ public sealed class NavigationRequest : MultiTenantEntityBase
/// <summary> /// <summary>
/// 用户 ID。 /// 用户 ID。
/// </summary> /// </summary>
public Guid UserId { get; set; } public long UserId { get; set; }
/// <summary> /// <summary>
/// 门店 ID。 /// 门店 ID。
/// </summary> /// </summary>
public Guid StoreId { get; set; } public long StoreId { get; set; }
/// <summary> /// <summary>
/// 来源通道小程序、H5 等)。 /// 来源通道小程序、H5 等)。

View File

@@ -11,17 +11,17 @@ public sealed class CartItem : MultiTenantEntityBase
/// <summary> /// <summary>
/// 所属购物车标识。 /// 所属购物车标识。
/// </summary> /// </summary>
public Guid ShoppingCartId { get; set; } public long ShoppingCartId { get; set; }
/// <summary> /// <summary>
/// 商品或 SKU 标识。 /// 商品或 SKU 标识。
/// </summary> /// </summary>
public Guid ProductId { get; set; } public long ProductId { get; set; }
/// <summary> /// <summary>
/// SKU 标识。 /// SKU 标识。
/// </summary> /// </summary>
public Guid? ProductSkuId { get; set; } public long? ProductSkuId { get; set; }
/// <summary> /// <summary>
/// 商品名称快照。 /// 商品名称快照。

View File

@@ -10,7 +10,7 @@ public sealed class CartItemAddon : MultiTenantEntityBase
/// <summary> /// <summary>
/// 所属购物车条目。 /// 所属购物车条目。
/// </summary> /// </summary>
public Guid CartItemId { get; set; } public long CartItemId { get; set; }
/// <summary> /// <summary>
/// 选项名称。 /// 选项名称。
@@ -25,5 +25,5 @@ public sealed class CartItemAddon : MultiTenantEntityBase
/// <summary> /// <summary>
/// 选项 ID可对应 ProductAddonOption /// 选项 ID可对应 ProductAddonOption
/// </summary> /// </summary>
public Guid? OptionId { get; set; } public long? OptionId { get; set; }
} }

View File

@@ -11,12 +11,12 @@ public sealed class CheckoutSession : MultiTenantEntityBase
/// <summary> /// <summary>
/// 用户标识。 /// 用户标识。
/// </summary> /// </summary>
public Guid UserId { get; set; } public long UserId { get; set; }
/// <summary> /// <summary>
/// 门店标识。 /// 门店标识。
/// </summary> /// </summary>
public Guid StoreId { get; set; } public long StoreId { get; set; }
/// <summary> /// <summary>
/// 会话 Token。 /// 会话 Token。

View File

@@ -11,12 +11,12 @@ public sealed class ShoppingCart : MultiTenantEntityBase
/// <summary> /// <summary>
/// 用户标识。 /// 用户标识。
/// </summary> /// </summary>
public Guid UserId { get; set; } public long UserId { get; set; }
/// <summary> /// <summary>
/// 门店标识。 /// 门店标识。
/// </summary> /// </summary>
public Guid StoreId { get; set; } public long StoreId { get; set; }
/// <summary> /// <summary>
/// 购物车状态,包含正常/锁定。 /// 购物车状态,包含正常/锁定。

View File

@@ -17,7 +17,7 @@ public sealed class Order : MultiTenantEntityBase
/// <summary> /// <summary>
/// 门店。 /// 门店。
/// </summary> /// </summary>
public Guid StoreId { get; set; } public long StoreId { get; set; }
/// <summary> /// <summary>
/// 下单渠道。 /// 下单渠道。
@@ -62,7 +62,7 @@ public sealed class Order : MultiTenantEntityBase
/// <summary> /// <summary>
/// 预约 ID。 /// 预约 ID。
/// </summary> /// </summary>
public Guid? ReservationId { get; set; } public long? ReservationId { get; set; }
/// <summary> /// <summary>
/// 商品总额。 /// 商品总额。

View File

@@ -10,12 +10,12 @@ public sealed class OrderItem : MultiTenantEntityBase
/// <summary> /// <summary>
/// 订单 ID。 /// 订单 ID。
/// </summary> /// </summary>
public Guid OrderId { get; set; } public long OrderId { get; set; }
/// <summary> /// <summary>
/// 商品 ID。 /// 商品 ID。
/// </summary> /// </summary>
public Guid ProductId { get; set; } public long ProductId { get; set; }
/// <summary> /// <summary>
/// 商品名称。 /// 商品名称。

View File

@@ -11,7 +11,7 @@ public sealed class OrderStatusHistory : MultiTenantEntityBase
/// <summary> /// <summary>
/// 订单标识。 /// 订单标识。
/// </summary> /// </summary>
public Guid OrderId { get; set; } public long OrderId { get; set; }
/// <summary> /// <summary>
/// 变更后的状态。 /// 变更后的状态。
@@ -21,7 +21,7 @@ public sealed class OrderStatusHistory : MultiTenantEntityBase
/// <summary> /// <summary>
/// 操作人标识(可为空表示系统)。 /// 操作人标识(可为空表示系统)。
/// </summary> /// </summary>
public Guid? OperatorId { get; set; } public long? OperatorId { get; set; }
/// <summary> /// <summary>
/// 备注信息。 /// 备注信息。

View File

@@ -11,7 +11,7 @@ public sealed class RefundRequest : MultiTenantEntityBase
/// <summary> /// <summary>
/// 关联订单标识。 /// 关联订单标识。
/// </summary> /// </summary>
public Guid OrderId { get; set; } public long OrderId { get; set; }
/// <summary> /// <summary>
/// 退款单号。 /// 退款单号。

View File

@@ -0,0 +1,69 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Domain.Orders.Entities;
using TakeoutSaaS.Domain.Orders.Enums;
using TakeoutSaaS.Domain.Payments.Enums;
namespace TakeoutSaaS.Domain.Orders.Repositories;
/// <summary>
/// 订单聚合仓储契约。
/// </summary>
public interface IOrderRepository
{
/// <summary>
/// 依据标识获取订单。
/// </summary>
Task<Order?> FindByIdAsync(long orderId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 依据订单号获取订单。
/// </summary>
Task<Order?> FindByOrderNoAsync(string orderNo, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 按状态筛选订单列表。
/// </summary>
Task<IReadOnlyList<Order>> SearchAsync(long tenantId, OrderStatus? status, PaymentStatus? paymentStatus, CancellationToken cancellationToken = default);
/// <summary>
/// 获取订单明细行。
/// </summary>
Task<IReadOnlyList<OrderItem>> GetItemsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取订单状态流转记录。
/// </summary>
Task<IReadOnlyList<OrderStatusHistory>> GetStatusHistoryAsync(long orderId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取订单退款申请。
/// </summary>
Task<IReadOnlyList<RefundRequest>> GetRefundsAsync(long orderId, long tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// 新增订单。
/// </summary>
Task AddOrderAsync(Order order, CancellationToken cancellationToken = default);
/// <summary>
/// 新增订单明细。
/// </summary>
Task AddItemsAsync(IEnumerable<OrderItem> items, CancellationToken cancellationToken = default);
/// <summary>
/// 新增订单状态记录。
/// </summary>
Task AddStatusHistoryAsync(OrderStatusHistory history, CancellationToken cancellationToken = default);
/// <summary>
/// 新增退款申请。
/// </summary>
Task AddRefundAsync(RefundRequest refund, CancellationToken cancellationToken = default);
/// <summary>
/// 持久化变更。
/// </summary>
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -11,7 +11,7 @@ public sealed class PaymentRecord : MultiTenantEntityBase
/// <summary> /// <summary>
/// 关联订单。 /// 关联订单。
/// </summary> /// </summary>
public Guid OrderId { get; set; } public long OrderId { get; set; }
/// <summary> /// <summary>
/// 支付方式。 /// 支付方式。

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