diff --git a/ANNOUNCEMENT_DELIVERY_REPORT.md b/ANNOUNCEMENT_DELIVERY_REPORT.md deleted file mode 100644 index 7fee55c..0000000 --- a/ANNOUNCEMENT_DELIVERY_REPORT.md +++ /dev/null @@ -1,467 +0,0 @@ -# 公告管理功能交付报告 - -**项目名称**: TakeoutSaaS 多租户公告管理系统 -**交付日期**: 2025-12-20 -**版本**: 1.0.0 -**状态**: ✅ 生产就绪 - ---- - -## 📋 执行摘要 - -成功交付了完整的多租户公告管理功能,包括平台级和租户级公告的创建、发布、撤销、查询和目标受众定向推送。所有功能均通过严格测试验证,代码质量达到A级(优秀),符合企业级生产标准。 - -### **关键成果** -- ✅ **36个测试全部通过**(27单元 + 9集成) -- ✅ **性能优化**:查询处理器支持1000条数据 < 5秒 -- ✅ **安全可靠**:并发控制、多租户隔离、权限验证完整 -- ✅ **架构优秀**:CQRS + DDD + 领域事件,分层清晰 - ---- - -## 🎯 功能覆盖 - -### **核心功能模块** - -#### 1. **公告管理(CRUD)** -- ✅ 创建公告(租户级/平台级) -- ✅ 更新公告(仅草稿状态) -- ✅ 删除公告 -- ✅ 查询公告(分页、过滤、排序) -- ✅ 查询未读公告统计 - -#### 2. **状态机管理** -- ✅ 草稿 → 已发布(`PublishAnnouncementCommand`) -- ✅ 已发布 → 已撤销(`RevokeAnnouncementCommand`) -- ✅ 已撤销 → 已发布(重新发布) -- ✅ 状态转换时间戳记录(PublishedAt, RevokedAt) - -#### 3. **目标受众定向** -- ✅ 全部租户(ALL_TENANTS) -- ✅ 指定角色(ROLES: admin, ops, user) -- ✅ 指定用户(USERS: userId列表) -- ✅ 组合条件(USERS + ROLES) - -#### 4. **已读/未读管理** -- ✅ 标记已读(用户级/租户级) -- ✅ 批量已读查询 -- ✅ 未读统计 - -#### 5. **权限控制** -- ✅ 平台管理员:CRUD平台公告 -- ✅ 租户管理员:CRUD租户公告 -- ✅ 普通用户:只读权限 - ---- - -## 🏗️ 技术架构 - -### **架构模式** -- **CQRS**:命令查询职责分离(MediatR) -- **DDD**:领域驱动设计(Domain, Application, Infrastructure) -- **仓储模式**:`ITenantAnnouncementRepository` -- **事件驱动**:`AnnouncementPublished`, `AnnouncementRevoked` - -### **关键技术栈** -- .NET 10 / C# 13 -- Entity Framework Core(PostgreSQL) -- FluentValidation(输入验证) -- xUnit + FluentAssertions(测试) -- Swagger/OpenAPI(API文档) - -### **数据库设计** -**表结构**: `tenant_announcements` -- 主键:`Id` (bigint) -- 多租户隔离:`TenantId` (0=平台,>0=租户) -- 状态机:`Status` (Draft/Published/Revoked) -- 并发控制:`RowVersion` (bytea, 乐观锁) -- 目标受众:`TargetType` + `TargetParameters` (JSON) -- 生效时间:`EffectiveFrom`, `EffectiveTo`, `ScheduledPublishAt` - -**索引**: -```sql -ix_tenant_announcements_status_priority -ix_tenant_announcements_tenant_effective -``` - ---- - -## 🔧 Phase 7 代码审查与修复 - -### **修复的关键问题** - -#### ✅ 问题 #1: RowVersion 并发控制(13个测试失败) -**根本原因**: -- EF Core 使用 `IsRowVersion()` 期望数据库自动生成值 -- PostgreSQL bytea 不会自动生成,导致 INSERT 时为 NULL - -**修复方案**: -1. 修改 EF Core 配置:`IsRowVersion()` → `IsConcurrencyToken()` -2. 验证器添加 null 检查:`Must(rowVersion => rowVersion != null && rowVersion.Length > 0)` -3. 测试 SQL 参数语法修正:`$p0` → `{0}` - -**影响文件**: -- `TakeoutAppDbContext.cs` (6处配置修改) -- 3个验证器(Publish, Revoke, Update) -- `AnnouncementWorkflowTests.cs` - -**结果**: ✅ 所有13个测试通过 - ---- - -#### ✅ 问题 #2: 查询性能优化 -**问题描述**: -- 查询处理器在内存中过滤、排序和分页 -- 1000条数据时性能差 - -**优化方案**: -1. **仓储层**: 添加 `orderByPriority` 和 `limit` 参数 -2. **数据库层**: 应用 `ORDER BY priority DESC, effective_from DESC` + `TAKE` -3. **应用层**: 使用估算限制(`page × size × 3`)避免全量加载 -4. **权衡**: TotalCount 从精确值改为近似值 - -**修改文件**: -- `ITenantAnnouncementRepository.cs:21-22` (接口) -- `EfTenantAnnouncementRepository.cs:63-73` (实现) -- `GetTenantsAnnouncementsQueryHandler.cs:36-55` (处理器) -- `AnnouncementQueryPerformanceTests.cs:70-76` (测试) - -**结果**: ✅ 性能测试通过(1000条数据 < 5秒) - ---- - -#### ✅ 问题 #3: ContinueWith 模式现代化 -**问题**: 使用过时的 `ContinueWith` 模式 - -**修复**: -```csharp -// 修复前 -return query.ToListAsync(cancellationToken) - .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); - -// 修复后 -return await query.ToListAsync(cancellationToken); -``` - -**结果**: ✅ 代码更清晰,测试通过 - ---- - -#### ✅ 问题 #4: 单元测试参数不匹配 -**问题**: Mock setup 缺少新增的 `orderByPriority` 和 `limit` 参数 - -**修复**: -- 更新 Mock setup 和 Verify 调用 -- 修正 estimatedLimit 计算(page × size × 3 = 2 × 2 × 3 = 12) -- Mock 返回排序后的数据模拟数据库行为 - -**结果**: ✅ 27个单元测试全部通过 - ---- - -### **验证的问题(假阳性)** - -#### ✅ ExecuteAsPlatformAsync 线程安全 -**专家审计标记**: 🟠 High - 潜在线程安全问题 - -**验证结果**: ✅ 无问题 -- `TenantContextAccessor` 正确使用 `AsyncLocal`(line 8) -- `ExecuteAsPlatformAsync` 使用 try-finally 确保状态恢复 -- 多租户上下文隔离机制正确 - -**结论**: 代码审查工具假阳性,无需修复 - ---- - -## 📊 测试验证 - -### **测试覆盖** - -| 测试类型 | 数量 | 状态 | 覆盖范围 | -|---------|------|------|---------| -| **单元测试** | 27 | ✅ 通过 | 命令验证器、查询处理器、目标过滤 | -| **集成测试** | 9 | ✅ 通过 | 状态机、仓储、并发控制 | -| **性能测试** | 1 | ✅ 通过 | 1000条数据查询 < 5秒 | -| **总计** | 37 | ✅ **100%通过** | 全功能覆盖 | - -### **关键测试场景** - -#### 单元测试 -- ✅ 命令验证(FluentValidation) -- ✅ RowVersion null 检查 -- ✅ 查询参数传递正确性 -- ✅ 目标受众过滤逻辑 -- ✅ ScheduledPublishAt 过滤 - -#### 集成测试 -- ✅ 发布公告(Draft → Published) -- ✅ 撤销公告(Published → Revoked) -- ✅ 重新发布(Revoked → Published) -- ✅ 更新限制(Published状态不可更新) -- ✅ 并发控制(DbUpdateConcurrencyException) -- ✅ 仓储查询(多租户作用域) -- ✅ 未读统计 - ---- - -## 🔍 最终代码质量审计 - -### **审计结果**:A(优秀) - -由 gemini-2.5-pro 执行的专家级代码审计,覆盖8个核心文件: - -#### **✅ 质量(Excellent)** -- CQRS模式实现规范,MediatR集成良好 -- DDD边界清晰,职责分离 -- 命名规范一致,文档完整 -- 代码可读性强,无过度复杂 - -#### **✅ 安全(Good)** -- 并发控制:乐观锁(RowVersion)机制正确 -- 输入验证:FluentValidation 覆盖所有命令 -- SQL注入防护:参数化查询 -- 多租户隔离:TenantId过滤严格 -- 权限检查:TargetTypeFilter + 权限属性 - -#### **✅ 性能(Optimized)** -- 数据库端排序和限制 -- 批量查询(已读状态) -- 估算限制策略减少内存占用 -- 异步操作(async/await) -- AsNoTracking(只读查询) - -#### **✅ 架构(Clean)** -- 依赖反转原则 -- 单一职责原则 -- 关注点分离 -- 事件驱动(领域事件) - ---- - -### **⚠️ 低优先级改进点** -以下问题已识别但不影响功能和性能: - -1. **TargetTypeFilter 异常日志缺失** - - 影响:JsonException 被吞没,影响可观测性 - - 建议:注入 ILogger 记录异常 - -2. **tenantIds 重复代码** - - 影响:轻微可维护性问题 - - 建议:提取 `GetTenantScope(long tenantId)` 辅助方法 - -3. **分页默认值硬编码** - - 影响:配置灵活性 - - 建议:从 appsettings.json 读取 - -4. **IsActive 弃用警告** - - 影响:编译警告 - - 建议:测试代码迁移到 Status 枚举 - ---- - -## 📦 交付物清单 - -### **源代码文件**(已提交) - -#### 领域层 (Domain) -- `TenantAnnouncement.cs` - 公告实体 -- `ITenantAnnouncementRepository.cs` - 仓储接口 -- `AnnouncementStatus.cs` - 状态枚举 -- `PublisherScope.cs` - 发布者范围 -- `TenantAnnouncementType.cs` - 公告类型 -- `AnnouncementPublished.cs` - 发布事件 -- `AnnouncementRevoked.cs` - 撤销事件 - -#### 应用层 (Application) -**命令**: -- `CreateTenantAnnouncementCommand.cs` -- `UpdateTenantAnnouncementCommand.cs` -- `PublishAnnouncementCommand.cs` -- `RevokeAnnouncementCommand.cs` - -**命令处理器**: -- `CreateTenantAnnouncementCommandHandler.cs` -- `UpdateTenantAnnouncementCommandHandler.cs` -- `PublishAnnouncementCommandHandler.cs` -- `RevokeAnnouncementCommandHandler.cs` - -**查询**: -- `GetTenantsAnnouncementsQuery.cs` -- `GetTenantsAnnouncementsQueryHandler.cs` - -**验证器**: -- `CreateAnnouncementCommandValidator.cs` -- `UpdateAnnouncementCommandValidator.cs` -- `PublishAnnouncementCommandValidator.cs` -- `RevokeAnnouncementCommandValidator.cs` - -**其他**: -- `TargetTypeFilter.cs` - 目标受众过滤 -- `AnnouncementTargetContext.cs` - 目标上下文 -- `TenantAnnouncementDto.cs` - DTO映射 - -#### 基础设施层 (Infrastructure) -- `EfTenantAnnouncementRepository.cs` - EF Core仓储 -- `EfTenantAnnouncementReadRepository.cs` - 已读仓储 -- `TakeoutAppDbContext.cs` - EF配置(RowVersion部分) -- `20251220160000_AddTenantAnnouncementStatusAndPublisher.cs` - 迁移 - -#### API层 -- `TenantAnnouncementsController.cs` - 租户API -- `PlatformAnnouncementsController.cs` - 平台API -- `AppAnnouncementsController.cs` - 应用端API - -#### 测试 -**单元测试**(27个): -- `GetTenantsAnnouncementsQueryHandlerTests.cs` -- `PublishAnnouncementCommandValidatorTests.cs` -- `RevokeAnnouncementCommandValidatorTests.cs` -- `UpdateAnnouncementCommandValidatorTests.cs` -- 其他业务逻辑测试... - -**集成测试**(9个): -- `AnnouncementWorkflowTests.cs` - 状态机测试 -- `AnnouncementRegressionTests.cs` - 回归测试 -- `AnnouncementQueryPerformanceTests.cs` - 性能测试 -- `TenantAnnouncementRepositoryScopeTests.cs` - 仓储测试 - -### **文档** -- ✅ `ANNOUNCEMENT_DELIVERY_REPORT.md` - 本交付报告 -- ✅ Swagger API 文档(运行时自动生成) -- ✅ XML注释(所有公开API) - ---- - -## 🚀 部署说明 - -### **数据库迁移** -```bash -# 应用迁移 -dotnet ef database update --project src/Infrastructure/TakeoutSaaS.Infrastructure - -# 迁移包含: -# - tenant_announcements 表创建 -# - Status 和 PublisherScope 列添加 -# - RowVersion 并发控制列 -# - 索引创建 -``` - -### **权限配置** -```sql --- 平台管理员权限(已包含在迁移中) -INSERT INTO permissions (name, category) VALUES - ('platform:announcement:create', 'Platform'), - ('platform:announcement:update', 'Platform'), - ('platform:announcement:publish', 'Platform'), - ('platform:announcement:revoke', 'Platform'), - ('platform:announcement:delete', 'Platform'), - ('platform:announcement:read', 'Platform'); -``` - -### **环境要求** -- .NET 10 Runtime -- PostgreSQL 14+ -- 支持 bytea 数据类型 - ---- - -## 📈 性能指标 - -### **查询性能** -| 数据量 | 查询时间 | 内存占用 | 目标 | -|--------|---------|---------|------| -| 100条 | < 100ms | < 5MB | ✅ 达标 | -| 1000条 | < 5s | < 50MB | ✅ 达标 | - -### **并发性能** -- 乐观锁机制:支持高并发读写 -- RowVersion 版本冲突检测:< 1% 冲突率(正常场景) - ---- - -## ✅ 验收标准 - -| 验收项 | 状态 | 备注 | -|--------|------|------| -| **功能完整性** | ✅ 通过 | 所有核心功能实现 | -| **测试覆盖率** | ✅ 通过 | 37个测试100%通过 | -| **性能达标** | ✅ 通过 | 1000条数据 < 5秒 | -| **安全合规** | ✅ 通过 | 多租户隔离、权限控制、并发安全 | -| **代码质量** | ✅ 通过 | A级(优秀) | -| **文档完整** | ✅ 通过 | API文档、交付报告、代码注释 | - ---- - -## 🎯 后续优化建议 - -虽然当前代码已达到生产就绪标准,但以下优化可在后续迭代中考虑: - -### **优先级:Low** -1. **TargetTypeFilter 可观测性** - - 添加 ILogger 注入 - - 记录 JsonException 详细信息 - -2. **代码重构** - - 提取 `GetTenantScope()` 辅助方法 - - 从配置读取分页默认值 - -3. **测试代码迁移** - - 移除 IsActive 弃用属性使用 - - 更新为 Status 枚举 - -### **优先级:考虑中** -4. **目标受众机制增强** - - 支持更复杂的组合条件 - - 可查询化的目标定向(避免内存过滤) - -5. **查询性能进一步优化** - - 引入 Redis 缓存热门公告 - - 全文搜索支持(PostgreSQL FTS) - ---- - -## 📞 支持与维护 - -### **关键联系人** -- 架构师:[待填写] -- 开发负责人:[待填写] -- 测试负责人:[待填写] - -### **已知限制** -1. **TotalCount 近似值**:性能优化权衡,用户可能看到估算的总数而非精确值 -2. **目标受众过滤**:复杂条件在内存中处理,超大数据量时可能需要进一步优化 - -### **监控建议** -- 监控公告查询响应时间(P95 < 2秒) -- 监控并发冲突率(< 1%) -- 监控未读公告数量增长 - ---- - -## 📜 变更历史 - -| 日期 | 版本 | 变更内容 | 负责人 | -|------|------|---------|--------| -| 2025-12-20 | 1.0.0 | 初始交付,包含所有核心功能 | Claude | -| 2025-12-20 | 1.0.0 | Phase 7修复(RowVersion、性能优化) | Claude | -| 2025-12-20 | 1.0.0 | Phase 8最终审计与交付 | Claude | - ---- - -## 🏆 项目总结 - -本项目成功交付了企业级多租户公告管理系统,完全满足所有功能和非功能需求。代码质量达到A级(优秀),架构设计符合DDD和CQRS最佳实践,性能和安全性经过严格验证。 - -**关键亮点**: -- ✅ **零遗留关键问题**:所有Phase 7识别的高优先级问题已修复 -- ✅ **100%测试通过率**:36个测试全部通过 -- ✅ **性能优化到位**:数据库端排序和限制,估算策略 -- ✅ **生产就绪**:安全、可靠、可维护 - -**交付状态**:**✅ 可立即部署到生产环境** - ---- - -**报告生成时间**: 2025-12-20 -**签署**: Claude Code -**版本**: 1.0.0 Final diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index a162221..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,40 +0,0 @@ -# 变更日志 - -> 最后更新日期:2025-12-20 - -## v2.1.0 - 2025-12-20 - -### 新增 -- 公告状态机(Draft/Published/Revoked)与平台公告(TenantId=0)支持。 -- 平台公告管理 API:创建、查询、更新、发布、撤销。 -- 租户公告发布/撤销端点与应用端公告列表/未读/已读端点。 -- 目标受众过滤(TargetType/TargetParameters)与用户已读记录。 -- 公告发布/撤销领域事件:`tenant-announcement.published`、`tenant-announcement.revoked`。 -- 公告权限与超级管理员角色默认授权迁移。 -- 公告模块单元测试/集成测试/性能测试基线。 -- 公告相关文档与 ADR。 - -### 变更 -- 公告查询逻辑支持 `TenantId IN (当前租户, 0)`,并按优先级与生效时间排序。 -- 公告更新限制为草稿状态;已发布公告必须先撤销再重发。 -- 新增 `RowVersion` 乐观并发控制字段。 -- 保留 `IsActive` 作为兼容字段,并同步到 `Status`。 - -### 修复 -- 标记已读操作幂等化,已读重复请求不再失败。 - -### 弃用 -- `IsActive` 作为主状态字段(保留但不再作为唯一状态依据)。 - -### 升级提示(示例) - -```bash -dotnet ef database update -``` - -```mermaid -flowchart LR - Start[升级开始] --> Db[执行迁移] - Db --> Api[更新 API] - Api --> Done[完成] -``` diff --git a/docs/adr/0001-announcement-status-state-machine.md b/docs/adr/0001-announcement-status-state-machine.md deleted file mode 100644 index 32a753c..0000000 --- a/docs/adr/0001-announcement-status-state-machine.md +++ /dev/null @@ -1,52 +0,0 @@ -# ADR 0001:公告状态机与多租户平台公告方案 - -> 最后更新日期:2025-12-20 - -## Context - -公告模块需要支持草稿、发布、撤销等完整生命周期,且平台与租户公告必须在同一数据模型中统一管理。同时要支持并发更新与审计追踪,避免已发布公告被“悄然修改”引发合规风险。 - -## Decision - -1. 使用 `Status` 枚举替代 `IsActive` 布尔值作为主状态字段(Draft / Published / Revoked)。 -2. `Published` 状态不可变:已发布公告不允许编辑,需先撤销再重新发布。 -3. 使用 `TenantId = 0` 表示平台公告,统一在 `tenant_announcements` 表中存储。 -4. 使用 `RowVersion` 字段进行乐观并发控制。 - -```csharp -public enum AnnouncementStatus -{ - Draft = 0, - Published = 1, - Revoked = 2 -} -``` - -```mermaid -stateDiagram-v2 - [*] --> Draft - Draft --> Published: publish - Published --> Revoked: revoke - Revoked --> Published: republish -``` - -## Consequences - -- **优点**: - - 状态语义清晰,支持审计与合规追踪。 - - 平台与租户公告统一查询与筛选逻辑(`TenantId IN (current, 0)`)。 - - `RowVersion` 能防止并发覆盖更新。 -- **代价**: - - 需要迁移与兼容历史 `IsActive` 字段。 - - 已发布公告不可编辑,操作流程增加一步(撤销后重发)。 - -## Alternatives Considered - -1. **继续使用 `IsActive`** - - 问题:无法表达撤销、草稿等状态,审计语义不足。 -2. **平台公告单独表** - - 问题:跨表查询复杂,重复实现过滤与排序。 -3. **使用悲观锁或数据库触发器** - - 问题:增加数据库负担,难以跨服务扩展。 - -> 该 ADR 对应迁移:`20251220160000_AddTenantAnnouncementStatusAndPublisher`。 diff --git a/docs/api/announcements-api.md b/docs/api/announcements-api.md deleted file mode 100644 index 19f2710..0000000 --- a/docs/api/announcements-api.md +++ /dev/null @@ -1,314 +0,0 @@ -# 公告管理 API 文档 - -> 最后更新日期:2025-12-20 - -本文档覆盖公告管理相关 API,包括平台公告、租户公告管理端接口,以及应用端(已认证用户)接口。 - -## 统一约定 - -- 认证方式:`Authorization: Bearer ` -- 时间字段均为 UTC(ISO 8601)。 -- 雪花 ID 以字符串形式序列化返回。 -- 统一响应结构:`ApiResponse`。 - -```json -{ - "success": true, - "code": 200, - "message": "操作成功", - "data": {}, - "errors": null, - "traceId": "01JH...", - "timestamp": "2025-12-20T12:00:00Z" -} -``` - -分页结构: -```json -{ - "items": [], - "page": 1, - "pageSize": 20, - "totalCount": 0, - "totalPages": 0 -} -``` - -## 关键枚举与字段 - -- `AnnouncementStatus`:`Draft(0)`、`Published(1)`、`Revoked(2)` -- `TenantAnnouncementType`:`System(0)`、`Billing(1)`、`Operation(2)`、`SYSTEM_PLATFORM_UPDATE(3)`、`SYSTEM_SECURITY_NOTICE(4)`、`SYSTEM_COMPLIANCE(5)`、`TENANT_INTERNAL(6)`、`TENANT_FINANCE(7)`、`TENANT_OPERATION(8)` -- `PublisherScope`:`Platform(0)`、`Tenant(1)`(只读字段) -- `RowVersion`:并发控制字段(Base64 字符串)。 - -## 目标受众(TargetType / TargetParameters) - -系统使用 `TargetType`(不区分大小写)+ `TargetParameters(JSON)` 过滤可见公告: - -- `ALL_TENANTS`:平台全量(可带约束) -- `TENANT_ALL`:单租户全量 -- `SPECIFIC_TENANTS` -- `USERS` / `SPECIFIC_USERS` / `USER_IDS` -- `ROLES` / `ROLE` -- `PERMISSIONS` / `PERMISSION` -- `MERCHANTS` / `MERCHANT_IDS` - -`TargetParameters` 示例: -```json -{ - "tenantIds": [100000000000000001], - "userIds": [200000000000000001], - "merchantIds": [300000000000000001], - "roles": ["OpsManager"], - "permissions": ["tenant-announcement:read"], - "departments": ["NorthRegion"] -} -``` - -注意:`TargetParameters` 为字符串 JSON;解析失败会导致公告对该用户不可见(失败即隐藏)。 - -## 数据流(示意) - -```mermaid -flowchart LR - Client[客户端] --> API[API Controller] - API --> Mediator[MediatR] - Mediator --> Handler[Query/Command Handler] - Handler --> Repo[Repository] - Repo --> DB[(PostgreSQL)] -``` - ---- - -# 平台公告 API - -> 路由前缀:`/api/platform/announcements`(无版本前缀) - -### 1) 创建平台公告 -- **方法**:POST -- **路径**:`/api/platform/announcements` -- **权限**:`platform-announcement:create` -- **请求体**:`CreateTenantAnnouncementCommand` -- **响应**:`ApiResponse` -- **错误码**:400 / 403 - -请求示例: -```json -{ - "title": "平台升级通知", - "content": "系统将于今晚 23:00 维护。", - "announcementType": 0, - "priority": 10, - "effectiveFrom": "2025-12-20T00:00:00Z", - "effectiveTo": null, - "targetType": "all_tenants", - "targetParameters": null -} -``` - -响应示例: -```json -{ - "success": true, - "code": 200, - "data": { - "id": "900123456789012345", - "tenantId": "0", - "title": "平台升级通知", - "status": "Draft" - } -} -``` - -### 2) 查询平台公告列表 -- **方法**:GET -- **路径**:`/api/platform/announcements` -- **权限**:`platform-announcement:create` -- **查询参数**: - - `page` / `pageSize` - - `status`(Draft/Published/Revoked) - - `announcementType` - - `isActive` - - `effectiveFrom` / `effectiveTo` - - `onlyEffective` -- **响应**:`ApiResponse>` -- **错误码**:403 - -示例:`GET /api/platform/announcements?page=1&pageSize=20&status=Published` - -### 3) 获取平台公告详情 -- **方法**:GET -- **路径**:`/api/platform/announcements/{announcementId}` -- **权限**:`platform-announcement:create` -- **响应**:`ApiResponse` -- **错误码**:403 / 404 - -### 4) 更新平台公告(仅草稿) -- **方法**:PUT -- **路径**:`/api/platform/announcements/{announcementId}` -- **权限**:`platform-announcement:create` -- **请求体**:`UpdateTenantAnnouncementCommand` -- **响应**:`ApiResponse` -- **错误码**:403 / 404 / 409 - -请求示例: -```json -{ - "title": "平台升级通知(更新)", - "content": "维护时间调整为 23:30。", - "targetType": "all_tenants", - "targetParameters": null, - "rowVersion": "AAAAAAAAB9E=" -} -``` - -### 5) 发布平台公告 -- **方法**:POST -- **路径**:`/api/platform/announcements/{announcementId}/publish` -- **权限**:`platform-announcement:publish` -- **请求体**:`PublishAnnouncementCommand` -- **响应**:`ApiResponse` -- **错误码**:403 / 404 / 409 - -请求示例: -```json -{ "rowVersion": "AAAAAAAAB9E=" } -``` - -### 6) 撤销平台公告 -- **方法**:POST -- **路径**:`/api/platform/announcements/{announcementId}/revoke` -- **权限**:`platform-announcement:revoke` -- **请求体**:`RevokeAnnouncementCommand` -- **响应**:`ApiResponse` -- **错误码**:403 / 404 / 409 - -请求示例: -```json -{ "rowVersion": "AAAAAAAAB9E=" } -``` - ---- - -# 租户公告管理 API(管理端) - -> 路由前缀:`/api/admin/v{version}/tenants/{tenantId}/announcements` - -### 1) 查询租户公告列表 -- **方法**:GET -- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements` -- **权限**:`tenant-announcement:read` -- **查询参数**: - - `page` / `pageSize` - - `status` / `announcementType` - - `isActive` / `effectiveFrom` / `effectiveTo` / `onlyEffective` -- **响应**:`ApiResponse>` -- **错误码**:403 - -### 2) 获取租户公告详情 -- **方法**:GET -- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements/{announcementId}` -- **权限**:`tenant-announcement:read` -- **响应**:`ApiResponse` -- **错误码**:403 / 404 - -### 3) 创建租户公告 -- **方法**:POST -- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements` -- **权限**:`tenant-announcement:create` -- **请求体**:`CreateTenantAnnouncementCommand` -- **响应**:`ApiResponse` -- **错误码**:400 / 403 - -请求示例: -```json -{ - "title": "租户公告", - "content": "新品上线提醒", - "announcementType": 0, - "priority": 5, - "effectiveFrom": "2025-12-20T00:00:00Z", - "targetType": "roles", - "targetParameters": "{\"roles\":[\"OpsManager\"]}" -} -``` - -### 4) 更新租户公告(仅草稿) -- **方法**:PUT -- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements/{announcementId}` -- **权限**:`tenant-announcement:update` -- **请求体**:`UpdateTenantAnnouncementCommand` -- **响应**:`ApiResponse` -- **错误码**:403 / 404 / 409 - -### 5) 发布租户公告 -- **方法**:POST -- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements/{announcementId}/publish` -- **权限**:`tenant-announcement:publish` -- **请求体**:`PublishAnnouncementCommand` -- **响应**:`ApiResponse` -- **错误码**:403 / 404 / 409 - -### 6) 撤销租户公告 -- **方法**:POST -- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements/{announcementId}/revoke` -- **权限**:`tenant-announcement:revoke` -- **请求体**:`RevokeAnnouncementCommand` -- **响应**:`ApiResponse` -- **错误码**:403 / 404 / 409 - -### 7) 删除租户公告 -- **方法**:DELETE -- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements/{announcementId}` -- **权限**:`tenant-announcement:delete` -- **响应**:`ApiResponse` -- **错误码**:403 - -### 8) 标记公告已读(兼容旧路径) -- **方法**:POST -- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements/{announcementId}/read` -- **权限**:`tenant-announcement:read` -- **响应**:`ApiResponse` -- **错误码**:403 / 404 - ---- - -# 应用端公告 API(已认证用户) - -> 路由前缀:`/api/app/announcements`(目前挂载在 AdminApi) - -### 1) 获取可见公告列表 -- **方法**:GET -- **路径**:`/api/app/announcements` -- **权限**:登录即可 -- **查询参数**:`page` / `pageSize`(其他筛选参数会被覆盖为已发布与有效期内) -- **响应**:`ApiResponse>` -- **错误码**:401 - -### 2) 获取未读公告列表 -- **方法**:GET -- **路径**:`/api/app/announcements/unread` -- **权限**:登录即可 -- **查询参数**:`page` / `pageSize` -- **响应**:`ApiResponse>` -- **错误码**:401 - -### 3) 标记公告已读 -- **方法**:POST -- **路径**:`/api/app/announcements/{announcementId}/mark-read` -- **权限**:登录即可 -- **请求体**:无 -- **响应**:`ApiResponse` -- **错误码**:401 / 404 - ---- - -## 常见错误码 - -- **400**:参数验证失败 -- **401**:未认证 -- **403**:无权限 -- **404**:公告不存在或不可见 -- **409**:状态冲突(例如已发布不可编辑) - -> 提示:实际错误码与消息由 `BusinessException` 和中间件统一返回。 diff --git a/docs/migrations/announcement-status-migration.md b/docs/migrations/announcement-status-migration.md deleted file mode 100644 index beb79a3..0000000 --- a/docs/migrations/announcement-status-migration.md +++ /dev/null @@ -1,98 +0,0 @@ -# 公告状态迁移说明 - -> 最后更新日期:2025-12-20 - -本文档说明公告状态相关迁移的目的、数据变化与回滚策略。 - -## 迁移列表 - -1. `20251220160000_AddTenantAnnouncementStatusAndPublisher.cs` -2. `20251220183000_GrantAnnouncementPermissionsToSuperAdmin.cs` - -## 迁移目的 - -- 引入公告状态机(`Status`)与发布者信息(`PublisherScope/PublisherUserId`)。 -- 增加目标受众字段(`TargetType/TargetParameters`)。 -- 增加 `RowVersion` 以支持乐观并发。 -- 预置公告相关权限到超级管理员角色。 - -## 数据结构变化 - -### 迁移前(tenant_announcements) - -| 字段 | 说明 | -| --- | --- | -| `IsActive` | 是否启用(旧字段) | -| `EffectiveFrom` / `EffectiveTo` | 生效区间 | -| 其他基础字段 | 标题、内容、类型等 | - -### 迁移后(新增字段) - -| 字段 | 说明 | -| --- | --- | -| `Status` | 公告状态(Draft/Published/Revoked) | -| `PublisherScope` | 发布者范围(Platform/Tenant) | -| `PublisherUserId` | 发布者用户 ID | -| `PublishedAt` / `RevokedAt` | 实际发布时间/撤销时间 | -| `ScheduledPublishAt` | 预定发布时间(暂未使用) | -| `TargetType` / `TargetParameters` | 目标受众筛选 | -| `RowVersion` | 并发控制版本 | - -### 索引新增 - -- `IX_tenant_announcements_TenantId_Status_EffectiveFrom` -- `IX_tenant_announcements_Status_EffectiveFrom_Platform`(TenantId=0) - -## 数据迁移逻辑 - -迁移中将历史 `IsActive` 映射到 `Status`: - -```sql -UPDATE tenant_announcements -SET "Status" = CASE WHEN "IsActive" THEN 1 ELSE 0 END; -``` - -## 权限迁移逻辑 - -迁移会为以下角色自动授予公告相关权限: - -- `super-admin` -- `SUPER_ADMIN` -- `PlatformAdmin` -- `platform-admin` - -并插入权限码: -`platform-announcement:create`、`platform-announcement:publish`、`platform-announcement:revoke`、 -`tenant-announcement:publish`、`tenant-announcement:revoke`。 - -## 回滚策略 - -1. **应用数据库(公告表)** - - 回滚到迁移前版本: - ```bash - dotnet ef database update <上一个迁移> - ``` - - 删除新增字段与索引(由 `Down` 执行)。 - -2. **身份数据库(权限)** - - 回滚迁移后,权限仅从 `role_permissions` 中删除,`permissions` 记录保留(符合当前 Down 逻辑)。 - -## 数据修复脚本(回滚后) - -如需恢复旧逻辑,可手动同步 `IsActive`: - -```sql -UPDATE tenant_announcements -SET "IsActive" = CASE WHEN "Status" = 1 THEN TRUE ELSE FALSE END; -``` - -```mermaid -flowchart TD - Start[备份数据库] --> M1[执行迁移 20251220160000] - M1 --> M2[执行迁移 20251220183000] - M2 --> Check[验证状态/权限] - Check -->|失败| Rollback[回滚并修复数据] - Check -->|成功| Done[发布] -``` - -> 建议在生产环境迁移前进行全量备份,并在灰度环境验证数据一致性。 diff --git a/docs/observability/announcement-events.md b/docs/observability/announcement-events.md deleted file mode 100644 index b4cc4ce..0000000 --- a/docs/observability/announcement-events.md +++ /dev/null @@ -1,57 +0,0 @@ -# 公告领域事件与可观测性 - -> 最后更新日期:2025-12-20 - -本文档列出公告领域事件及推荐监控指标,方便事件订阅与追踪。 - -## 事件清单 - -| 事件名 | 触发时机 | 载荷字段 | 备注 | -| --- | --- | --- | --- | -| `tenant-announcement.published` | 公告发布成功后 | `announcementId`、`publishedAt`、`targetType` | 对应 `AnnouncementPublished` | -| `tenant-announcement.revoked` | 公告撤销成功后 | `announcementId`、`revokedAt` | 对应 `AnnouncementRevoked` | - -### 事件载荷示例 - -```json -{ - "announcementId": 900123456789012345, - "publishedAt": "2025-12-20T12:00:00Z", - "targetType": "roles" -} -``` - -```json -{ - "announcementId": 900123456789012345, - "revokedAt": "2025-12-20T13:00:00Z" -} -``` - -## 事件流示意 - -```mermaid -flowchart LR - Cmd[Publish/Revoke Command] --> Handler[Handler] - Handler --> Bus[IEventPublisher] - Bus --> Topic[Event Bus] - Topic --> Sub1[通知服务] - Topic --> Sub2[审计/报表] -``` - -## 推荐指标 - -- `announcement.created.count`:公告创建次数 -- `announcement.published.count`:公告发布次数 -- `announcement.revoked.count`:公告撤销次数 -- `announcement.read.count`:公告已读次数 -- `announcement.visible.count`:用户可见公告数量(采样) -- `announcement.query.latency`:公告查询耗时(P95/P99) - -## 建议日志字段 - -```text -announcementId, tenantId, status, targetType, operatorUserId, traceId -``` - -> 事件发布位置:`PublishAnnouncementCommandHandler` 与 `RevokeAnnouncementCommandHandler`。 diff --git a/docs/permissions/announcement-permissions.md b/docs/permissions/announcement-permissions.md deleted file mode 100644 index d4272df..0000000 --- a/docs/permissions/announcement-permissions.md +++ /dev/null @@ -1,49 +0,0 @@ -# 公告权限清单 - -> 最后更新日期:2025-12-20 - -本文档列出公告管理新增权限,并说明默认授权对象与角色映射。 - -## 权限列表 - -| 权限码 | 用途 | 作用域 | 默认授权对象 | -| --- | --- | --- | --- | -| `platform-announcement:create` | 创建/查询/更新平台公告 | 平台 | 平台超级管理员角色(由迁移脚本授予) | -| `platform-announcement:publish` | 发布平台公告 | 平台 | 平台超级管理员角色(由迁移脚本授予) | -| `platform-announcement:revoke` | 撤销平台公告 | 平台 | 平台超级管理员角色(由迁移脚本授予) | -| `tenant-announcement:publish` | 发布租户公告 | 租户 | 超级管理员角色(由迁移脚本授予),租户自定义角色需手动授权 | -| `tenant-announcement:revoke` | 撤销租户公告 | 租户 | 超级管理员角色(由迁移脚本授予),租户自定义角色需手动授权 | - -> 说明:租户公告的 `create/read/update/delete` 权限为既有权限,本次新增主要是发布与撤销。 - -## 角色映射(默认迁移) - -迁移 `20251220183000_GrantAnnouncementPermissionsToSuperAdmin` 会为以下角色分配上述权限: - -- `super-admin` -- `SUPER_ADMIN` -- `PlatformAdmin` -- `platform-admin` - -```mermaid -flowchart LR - Role[平台超级管理员角色] --> P1[platform-announcement:create] - Role --> P2[platform-announcement:publish] - Role --> P3[platform-announcement:revoke] - Role --> P4[tenant-announcement:publish] - Role --> P5[tenant-announcement:revoke] -``` - -## 授权示例 - -如需为租户角色授予权限,可通过管理端或 SQL: - -```sql -INSERT INTO role_permissions ("TenantId", "RoleId", "PermissionId") -SELECT 100000000000000001, 900000000000000001, p."Id" -FROM permissions p -WHERE p."TenantId" = 100000000000000001 - AND p."Code" IN ('tenant-announcement:publish', 'tenant-announcement:revoke'); -``` - -> 建议在权限变更后刷新相关缓存或重新登录以生效。 diff --git a/docs/technical-debt.md b/docs/technical-debt.md deleted file mode 100644 index 33e56a2..0000000 --- a/docs/technical-debt.md +++ /dev/null @@ -1,31 +0,0 @@ -# 技术债务清单(公告模块) - -> 最后更新日期:2025-12-20 - -本文件用于记录公告模块的已知技术债务与后续改进建议。 - -```mermaid -flowchart TD - Debt[技术债务] --> Triage{优先级评估} - Triage -->|高| P1[修复并写回归测试] - Triage -->|中| P2[排期处理] - Triage -->|低| P3[文档跟踪] -``` - -## 记录项 - -| 编号 | 描述 | 影响 | 优先级 | 建议解决方案 | -| --- | --- | --- | --- | --- | -| TD-001 | `IsActive` 字段已废弃但保留用于兼容旧逻辑 | 读写逻辑需要同时维护 `Status` 与 `IsActive`,增加复杂度 | 中 | 完成一次性迁移后移除 `IsActive` 或改为只读计算字段 | -| TD-002 | 部分测试在特定数据库配置下出现 `RowVersion` 初始化/并发冲突问题 | 集成测试偶发失败,影响 CI 稳定性 | 中 | 统一测试数据库并确保 `RowVersion` 为数据库生成(避免默认空字节数组) | -| TD-003 | 计划功能未实现:定时发布、置顶公告 | 产品功能不完整,运营需求需人工执行 | 高 | 使用 `ScheduledPublishAt` 结合后台任务实现定时发布;新增置顶字段与排序策略 | - -## 修复示例(RowVersion 处理) - -```csharp -// 建议仅由数据库生成 RowVersion,不在业务层手动赋值默认空数组 -builder.Property(x => x.RowVersion) - .IsRowVersion(); -``` - -> 如需补充更多技术债务,请在此文件追加条目并注明日期。 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryLabelOverridesController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryLabelOverridesController.cs new file mode 100644 index 0000000..d031058 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/DictionaryLabelOverridesController.cs @@ -0,0 +1,176 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Application.Dictionary.Models; +using TakeoutSaaS.Application.Dictionary.Services; +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 字典标签覆盖管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/dictionary/label-overrides")] +public sealed class DictionaryLabelOverridesController( + DictionaryLabelOverrideService labelOverrideService, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor) + : BaseApiController +{ + private const string TenantIdHeaderName = "X-Tenant-Id"; + + #region 租户端 API(租户覆盖系统字典) + + /// + /// 获取当前租户的标签覆盖列表。 + /// + [HttpGet("tenant")] + [PermissionAuthorize("dictionary:override:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListTenantOverrides( + [FromQuery] OverrideType? overrideType, + CancellationToken cancellationToken) + { + var headerError = EnsureTenantHeader>(); + if (headerError != null) + { + return headerError; + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var result = await labelOverrideService.GetOverridesAsync(tenantId, overrideType, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 租户覆盖系统字典项的标签。 + /// + [HttpPost("tenant")] + [PermissionAuthorize("dictionary:override:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreateTenantOverride( + [FromBody] UpsertLabelOverrideRequest request, + CancellationToken cancellationToken) + { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var operatorId = currentUserAccessor.UserId; + var result = await labelOverrideService.UpsertTenantOverrideAsync(tenantId, request, operatorId, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 租户删除自己的标签覆盖。 + /// + [HttpDelete("tenant/{dictionaryItemId:long}")] + [PermissionAuthorize("dictionary:override:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> DeleteTenantOverride(long dictionaryItemId, CancellationToken cancellationToken) + { + var headerError = EnsureTenantHeader(); + if (headerError != null) + { + return headerError; + } + + var tenantId = tenantProvider.GetCurrentTenantId(); + var operatorId = currentUserAccessor.UserId; + var success = await labelOverrideService.DeleteOverrideAsync( + tenantId, + dictionaryItemId, + operatorId, + allowPlatformEnforcement: false, + cancellationToken); + return success + ? ApiResponse.Success() + : ApiResponse.Error(ErrorCodes.NotFound, "覆盖配置不存在"); + } + + #endregion + + #region 平台端 API(平台管理所有租户的覆盖) + + /// + /// 获取指定租户的所有标签覆盖(平台管理员用)。 + /// + [HttpGet("platform/{targetTenantId:long}")] + [PermissionAuthorize("dictionary:override:platform:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> ListPlatformOverrides( + long targetTenantId, + [FromQuery] OverrideType? overrideType, + CancellationToken cancellationToken) + { + var result = await labelOverrideService.GetOverridesAsync(targetTenantId, overrideType, cancellationToken); + return ApiResponse>.Ok(result); + } + + /// + /// 平台强制覆盖租户字典项的标签。 + /// + [HttpPost("platform/{targetTenantId:long}")] + [PermissionAuthorize("dictionary:override:platform:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> CreatePlatformOverride( + long targetTenantId, + [FromBody] UpsertLabelOverrideRequest request, + CancellationToken cancellationToken) + { + var operatorId = currentUserAccessor.UserId; + var result = await labelOverrideService.UpsertPlatformOverrideAsync(targetTenantId, request, operatorId, cancellationToken); + return ApiResponse.Ok(result); + } + + /// + /// 平台删除对租户的强制覆盖。 + /// + [HttpDelete("platform/{targetTenantId:long}/{dictionaryItemId:long}")] + [PermissionAuthorize("dictionary:override:platform:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> DeletePlatformOverride( + long targetTenantId, + long dictionaryItemId, + CancellationToken cancellationToken) + { + var operatorId = currentUserAccessor.UserId; + var success = await labelOverrideService.DeleteOverrideAsync( + targetTenantId, + dictionaryItemId, + operatorId, + cancellationToken: cancellationToken); + return success + ? ApiResponse.Success() + : ApiResponse.Error(ErrorCodes.NotFound, "覆盖配置不存在"); + } + + #endregion + + private ApiResponse? EnsureTenantHeader() + { + if (!Request.Headers.TryGetValue(TenantIdHeaderName, out var tenantHeader) || string.IsNullOrWhiteSpace(tenantHeader)) + { + return ApiResponse.Error(StatusCodes.Status400BadRequest, $"缺少租户标识,请在请求头 {TenantIdHeaderName} 指定租户"); + } + + if (!long.TryParse(tenantHeader.FirstOrDefault(), out _)) + { + return ApiResponse.Error(StatusCodes.Status400BadRequest, $"租户标识无效,请在请求头 {TenantIdHeaderName} 指定正确的租户 ID"); + } + + return null; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs index ce1411e..1e2f972 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs @@ -19,6 +19,7 @@ public static class DictionaryServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); return services; } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Models/LabelOverrideDto.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Models/LabelOverrideDto.cs new file mode 100644 index 0000000..39ab2a4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Models/LabelOverrideDto.cs @@ -0,0 +1,119 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.Dictionary.Models; + +/// +/// 字典标签覆盖 DTO。 +/// +public sealed class LabelOverrideDto +{ + /// + /// 覆盖记录 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long Id { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 被覆盖的字典项 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long DictionaryItemId { get; init; } + + /// + /// 字典项 Key。 + /// + public string DictionaryItemKey { get; init; } = string.Empty; + + /// + /// 原始显示值(多语言)。 + /// + public Dictionary OriginalValue { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// 覆盖后的显示值(多语言)。 + /// + public Dictionary OverrideValue { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// 覆盖类型。 + /// + public OverrideType OverrideType { get; init; } + + /// + /// 覆盖类型名称。 + /// + public string OverrideTypeName => OverrideType switch + { + OverrideType.TenantCustomization => "租户定制", + OverrideType.PlatformEnforcement => "平台强制", + _ => "未知" + }; + + /// + /// 覆盖原因/备注。 + /// + public string? Reason { get; init; } + + /// + /// 创建时间。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 更新时间。 + /// + public DateTime? UpdatedAt { get; init; } + + /// + /// 创建人 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? CreatedBy { get; init; } + + /// + /// 更新人 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long? UpdatedBy { get; init; } +} + +/// +/// 创建/更新标签覆盖请求。 +/// +public sealed class UpsertLabelOverrideRequest +{ + /// + /// 被覆盖的字典项 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long DictionaryItemId { get; init; } + + /// + /// 覆盖后的显示值(多语言)。 + /// + public Dictionary OverrideValue { get; init; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// 覆盖原因/备注(平台强制覆盖时建议填写)。 + /// + public string? Reason { get; init; } +} + +/// +/// 批量覆盖请求。 +/// +public sealed class BatchLabelOverrideRequest +{ + /// + /// 覆盖项列表。 + /// + public List Items { get; init; } = new(); +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAccessHelper.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAccessHelper.cs new file mode 100644 index 0000000..7b5b104 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAccessHelper.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Http; + +namespace TakeoutSaaS.Application.Dictionary.Services; + +internal static class DictionaryAccessHelper +{ + internal static bool IsPlatformAdmin(IHttpContextAccessor httpContextAccessor) + { + var user = httpContextAccessor.HttpContext?.User; + if (user?.Identity?.IsAuthenticated != true) + { + return false; + } + + return user.IsInRole("PlatformAdmin") || + user.IsInRole("platform-admin") || + user.IsInRole("super-admin") || + user.IsInRole("SUPER_ADMIN"); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs index 13365c6..2f62030 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryAppService.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Http; using System.Security.Cryptography; using TakeoutSaaS.Application.Dictionary.Abstractions; using TakeoutSaaS.Application.Dictionary.Contracts; @@ -19,6 +20,7 @@ public sealed class DictionaryAppService( IDictionaryRepository repository, IDictionaryCache cache, ITenantProvider tenantProvider, + IHttpContextAccessor httpContextAccessor, ILogger logger) : IDictionaryAppService { /// @@ -354,7 +356,7 @@ public sealed class DictionaryAppService( private void EnsureScopePermission(DictionaryScope scope) { var tenantId = tenantProvider.GetCurrentTenantId(); - if (scope == DictionaryScope.System && tenantId != 0) + if (scope == DictionaryScope.System && tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor)) { throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典"); } @@ -362,7 +364,7 @@ public sealed class DictionaryAppService( private void EnsurePlatformTenant(long tenantId) { - if (tenantId != 0) + if (tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor)) { throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典"); } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCommandService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCommandService.cs index 8be748a..fc4e65d 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCommandService.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryCommandService.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Http; using TakeoutSaaS.Application.Dictionary.Abstractions; using TakeoutSaaS.Application.Dictionary.Contracts; using TakeoutSaaS.Application.Dictionary.Models; @@ -21,6 +22,7 @@ public sealed class DictionaryCommandService( IDictionaryItemRepository itemRepository, IDictionaryHybridCache cache, ITenantProvider tenantProvider, + IHttpContextAccessor httpContextAccessor, ILogger logger) { /// @@ -229,7 +231,7 @@ public sealed class DictionaryCommandService( var tenantId = tenantProvider.GetCurrentTenantId(); if (scope == DictionaryScope.System) { - if (tenantId != 0) + if (tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor)) { throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可创建系统字典"); } @@ -248,7 +250,7 @@ public sealed class DictionaryCommandService( private void EnsureGroupAccess(DictionaryGroup group) { var tenantId = tenantProvider.GetCurrentTenantId(); - if (group.Scope == DictionaryScope.System && tenantId != 0) + if (group.Scope == DictionaryScope.System && tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor)) { throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典"); } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryImportExportService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryImportExportService.cs index b3553c4..ae62ad7 100644 --- a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryImportExportService.cs +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryImportExportService.cs @@ -14,6 +14,7 @@ using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Http; namespace TakeoutSaaS.Application.Dictionary.Services; @@ -29,6 +30,7 @@ public sealed class DictionaryImportExportService( IDictionaryHybridCache cache, ITenantProvider tenantProvider, ICurrentUserAccessor currentUser, + IHttpContextAccessor httpContextAccessor, ILogger logger) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -424,7 +426,7 @@ public sealed class DictionaryImportExportService( private void EnsureGroupAccess(DictionaryGroup group) { var tenantId = tenantProvider.GetCurrentTenantId(); - if (group.Scope == DictionaryScope.System && tenantId != 0) + if (group.Scope == DictionaryScope.System && tenantId != 0 && !DictionaryAccessHelper.IsPlatformAdmin(httpContextAccessor)) { throw new BusinessException(ErrorCodes.Forbidden, "仅平台管理员可操作系统字典"); } diff --git a/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryLabelOverrideService.cs b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryLabelOverrideService.cs new file mode 100644 index 0000000..4ab7183 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Dictionary/Services/DictionaryLabelOverrideService.cs @@ -0,0 +1,234 @@ +using TakeoutSaaS.Application.Dictionary.Models; +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Domain.Dictionary.Repositories; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; + +namespace TakeoutSaaS.Application.Dictionary.Services; + +/// +/// 字典标签覆盖服务。 +/// +public sealed class DictionaryLabelOverrideService( + IDictionaryLabelOverrideRepository overrideRepository, + IDictionaryItemRepository itemRepository) +{ + /// + /// 获取租户的所有标签覆盖。 + /// + /// 租户 ID。 + /// 可选的覆盖类型过滤。 + /// 取消标记。 + public async Task> GetOverridesAsync( + long tenantId, + OverrideType? overrideType = null, + CancellationToken cancellationToken = default) + { + var overrides = await overrideRepository.ListByTenantAsync(tenantId, overrideType, cancellationToken); + return overrides.Select(MapToDto).ToList(); + } + + /// + /// 获取指定字典项的覆盖配置。 + /// + /// 租户 ID。 + /// 字典项 ID。 + /// 取消标记。 + public async Task GetOverrideByItemIdAsync( + long tenantId, + long dictionaryItemId, + CancellationToken cancellationToken = default) + { + var entity = await overrideRepository.GetByItemIdAsync(tenantId, dictionaryItemId, cancellationToken); + return entity == null ? null : MapToDto(entity); + } + + /// + /// 创建或更新租户对系统字典的标签覆盖(租户定制)。 + /// + /// 租户 ID。 + /// 覆盖请求。 + /// 操作人 ID。 + /// 取消标记。 + public async Task UpsertTenantOverrideAsync( + long tenantId, + UpsertLabelOverrideRequest request, + long operatorId, + CancellationToken cancellationToken = default) + { + // 1. 验证字典项存在且为系统字典 + var item = await itemRepository.GetByIdAsync(request.DictionaryItemId, cancellationToken); + if (item == null) + { + throw new BusinessException(ErrorCodes.NotFound, "字典项不存在"); + } + + if (item.TenantId != 0) + { + throw new BusinessException(ErrorCodes.ValidationFailed, "租户只能覆盖系统字典项"); + } + + // 2. 查找现有覆盖或创建新记录 + var existing = await overrideRepository.GetByItemIdAsync(tenantId, request.DictionaryItemId, cancellationToken); + var now = DateTime.UtcNow; + + if (existing != null) + { + if (existing.OverrideType == OverrideType.PlatformEnforcement) + { + throw new BusinessException(ErrorCodes.Forbidden, "平台强制覆盖不可由租户修改"); + } + + existing.OverrideValue = DictionaryValueConverter.Serialize(request.OverrideValue); + existing.Reason = request.Reason; + existing.UpdatedAt = now; + existing.UpdatedBy = operatorId; + await overrideRepository.UpdateAsync(existing, cancellationToken); + } + else + { + existing = new DictionaryLabelOverride + { + TenantId = tenantId, + DictionaryItemId = request.DictionaryItemId, + OriginalValue = item.Value, + OverrideValue = DictionaryValueConverter.Serialize(request.OverrideValue), + OverrideType = OverrideType.TenantCustomization, + Reason = request.Reason, + CreatedAt = now, + CreatedBy = operatorId + }; + await overrideRepository.AddAsync(existing, cancellationToken); + } + + await overrideRepository.SaveChangesAsync(cancellationToken); + + // 重新加载以获取完整信息 + existing.DictionaryItem = item; + return MapToDto(existing); + } + + /// + /// 创建或更新平台对租户字典的强制覆盖(平台强制)。 + /// + /// 目标租户 ID。 + /// 覆盖请求。 + /// 操作人 ID。 + /// 取消标记。 + public async Task UpsertPlatformOverrideAsync( + long targetTenantId, + UpsertLabelOverrideRequest request, + long operatorId, + CancellationToken cancellationToken = default) + { + // 1. 验证字典项存在 + var item = await itemRepository.GetByIdAsync(request.DictionaryItemId, cancellationToken); + if (item == null) + { + throw new BusinessException(ErrorCodes.NotFound, "字典项不存在"); + } + + // 2. 查找现有覆盖或创建新记录 + var existing = await overrideRepository.GetByItemIdAsync(targetTenantId, request.DictionaryItemId, cancellationToken); + var now = DateTime.UtcNow; + + if (existing != null) + { + existing.OverrideValue = DictionaryValueConverter.Serialize(request.OverrideValue); + existing.OverrideType = OverrideType.PlatformEnforcement; + existing.Reason = request.Reason; + existing.UpdatedAt = now; + existing.UpdatedBy = operatorId; + await overrideRepository.UpdateAsync(existing, cancellationToken); + } + else + { + existing = new DictionaryLabelOverride + { + TenantId = targetTenantId, + DictionaryItemId = request.DictionaryItemId, + OriginalValue = item.Value, + OverrideValue = DictionaryValueConverter.Serialize(request.OverrideValue), + OverrideType = OverrideType.PlatformEnforcement, + Reason = request.Reason, + CreatedAt = now, + CreatedBy = operatorId + }; + await overrideRepository.AddAsync(existing, cancellationToken); + } + + await overrideRepository.SaveChangesAsync(cancellationToken); + + existing.DictionaryItem = item; + return MapToDto(existing); + } + + /// + /// 删除覆盖配置。 + /// + /// 租户 ID。 + /// 字典项 ID。 + /// 操作人 ID。 + /// 取消标记。 + public async Task DeleteOverrideAsync( + long tenantId, + long dictionaryItemId, + long operatorId, + bool allowPlatformEnforcement = true, + CancellationToken cancellationToken = default) + { + var existing = await overrideRepository.GetByItemIdAsync(tenantId, dictionaryItemId, cancellationToken); + if (existing == null) + { + return false; + } + + if (!allowPlatformEnforcement && existing.OverrideType == OverrideType.PlatformEnforcement) + { + throw new BusinessException(ErrorCodes.Forbidden, "平台强制覆盖不可由租户删除"); + } + + existing.DeletedBy = operatorId; + await overrideRepository.DeleteAsync(existing, cancellationToken); + await overrideRepository.SaveChangesAsync(cancellationToken); + return true; + } + + /// + /// 批量获取字典项的覆盖值映射。 + /// + /// 租户 ID。 + /// 字典项 ID 列表。 + /// 取消标记。 + /// 字典项 ID 到覆盖值的映射。 + public async Task>> GetOverrideValuesMapAsync( + long tenantId, + IEnumerable dictionaryItemIds, + CancellationToken cancellationToken = default) + { + var overrides = await overrideRepository.GetByItemIdsAsync(tenantId, dictionaryItemIds, cancellationToken); + return overrides.ToDictionary( + x => x.DictionaryItemId, + x => DictionaryValueConverter.Deserialize(x.OverrideValue)); + } + + private static LabelOverrideDto MapToDto(DictionaryLabelOverride entity) + { + return new LabelOverrideDto + { + Id = entity.Id, + TenantId = entity.TenantId, + DictionaryItemId = entity.DictionaryItemId, + DictionaryItemKey = entity.DictionaryItem?.Key ?? string.Empty, + OriginalValue = DictionaryValueConverter.Deserialize(entity.OriginalValue), + OverrideValue = DictionaryValueConverter.Deserialize(entity.OverrideValue), + OverrideType = entity.OverrideType, + Reason = entity.Reason, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt, + CreatedBy = entity.CreatedBy, + UpdatedBy = entity.UpdatedBy + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs index aeee4e7..e8772e7 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs @@ -422,6 +422,12 @@ public sealed class AdminAuthService( // 1.3 可见性 var required = node.RequiredPermissions ?? []; + if (required.Length == 0 && node.Meta.Permissions.Length > 0) + { + // Fall back to meta permissions when explicit required permissions are missing. + required = node.Meta.Permissions; + } + var visible = required.Length == 0 || required.Any(permissionSet.Contains); // 1.4 收集 diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryLabelOverride.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryLabelOverride.cs new file mode 100644 index 0000000..0d183c7 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Entities/DictionaryLabelOverride.cs @@ -0,0 +1,75 @@ +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Shared.Abstractions.Entities; + +namespace TakeoutSaaS.Domain.Dictionary.Entities; + +/// +/// 字典标签覆盖配置。 +/// +public sealed class DictionaryLabelOverride : EntityBase, IMultiTenantEntity, IAuditableEntity +{ + /// + /// 所属租户 ID(覆盖目标租户)。 + /// + public long TenantId { get; set; } + + /// + /// 被覆盖的字典项 ID。 + /// + public long DictionaryItemId { get; set; } + + /// + /// 原始显示值(JSON 格式,多语言)。 + /// + public string OriginalValue { get; set; } = "{}"; + + /// + /// 覆盖后的显示值(JSON 格式,多语言)。 + /// + public string OverrideValue { get; set; } = "{}"; + + /// + /// 覆盖类型。 + /// + public OverrideType OverrideType { get; set; } + + /// + /// 覆盖原因/备注。 + /// + public string? Reason { get; set; } + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; set; } + + /// + /// 最近更新时间(UTC)。 + /// + public DateTime? UpdatedAt { get; set; } + + /// + /// 删除时间(UTC)。 + /// + public DateTime? DeletedAt { get; set; } + + /// + /// 创建人用户标识。 + /// + public long? CreatedBy { get; set; } + + /// + /// 最后更新人用户标识。 + /// + public long? UpdatedBy { get; set; } + + /// + /// 删除人用户标识。 + /// + public long? DeletedBy { get; set; } + + /// + /// 导航属性:被覆盖的字典项。 + /// + public DictionaryItem? DictionaryItem { get; set; } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/OverrideType.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/OverrideType.cs new file mode 100644 index 0000000..f235a03 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Enums/OverrideType.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Domain.Dictionary.Enums; + +/// +/// 字典覆盖类型。 +/// +public enum OverrideType +{ + /// + /// 租户定制:租户覆盖系统字典的显示值。 + /// + TenantCustomization = 1, + + /// + /// 平台强制:平台管理员强制修正租户字典的显示值。 + /// + PlatformEnforcement = 2 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryLabelOverrideRepository.cs b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryLabelOverrideRepository.cs new file mode 100644 index 0000000..12afd85 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Dictionary/Repositories/IDictionaryLabelOverrideRepository.cs @@ -0,0 +1,65 @@ +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.Dictionary.Enums; + +namespace TakeoutSaaS.Domain.Dictionary.Repositories; + +/// +/// 字典标签覆盖仓储契约。 +/// +public interface IDictionaryLabelOverrideRepository +{ + /// + /// 根据 ID 获取覆盖配置。 + /// + Task GetByIdAsync(long id, CancellationToken cancellationToken = default); + + /// + /// 获取指定字典项的覆盖配置。 + /// + /// 租户 ID。 + /// 字典项 ID。 + /// 取消标记。 + Task GetByItemIdAsync(long tenantId, long dictionaryItemId, CancellationToken cancellationToken = default); + + /// + /// 获取租户的所有覆盖配置。 + /// + /// 租户 ID。 + /// 可选的覆盖类型过滤。 + /// 取消标记。 + Task> ListByTenantAsync( + long tenantId, + OverrideType? overrideType = null, + CancellationToken cancellationToken = default); + + /// + /// 批量获取多个字典项的覆盖配置。 + /// + /// 租户 ID。 + /// 字典项 ID 列表。 + /// 取消标记。 + Task> GetByItemIdsAsync( + long tenantId, + IEnumerable dictionaryItemIds, + CancellationToken cancellationToken = default); + + /// + /// 新增覆盖配置。 + /// + Task AddAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default); + + /// + /// 更新覆盖配置。 + /// + Task UpdateAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default); + + /// + /// 删除覆盖配置。 + /// + Task DeleteAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default); + + /// + /// 持久化更改。 + /// + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs index 66e0f41..3d10e96 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Common/Persistence/TenantAwareDbContext.cs @@ -1,9 +1,11 @@ using Microsoft.EntityFrameworkCore; using System.Reflection; +using System.Linq; using TakeoutSaaS.Shared.Abstractions.Entities; using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; +using Microsoft.AspNetCore.Http; namespace TakeoutSaaS.Infrastructure.Common.Persistence; @@ -14,9 +16,18 @@ public abstract class TenantAwareDbContext( DbContextOptions options, ITenantProvider tenantProvider, ICurrentUserAccessor? currentUserAccessor = null, - IIdGenerator? idGenerator = null) : AppDbContext(options, currentUserAccessor, idGenerator) + IIdGenerator? idGenerator = null, + IHttpContextAccessor? httpContextAccessor = null) : AppDbContext(options, currentUserAccessor, idGenerator) { private readonly ITenantProvider _tenantProvider = tenantProvider ?? throw new ArgumentNullException(nameof(tenantProvider)); + private readonly IHttpContextAccessor? _httpContextAccessor = httpContextAccessor; + private static readonly string[] PlatformRoleCodes = + { + "super-admin", + "SUPER_ADMIN", + "PlatformAdmin", + "platform-admin" + }; /// /// 当前请求租户 ID。 @@ -75,8 +86,22 @@ public abstract class TenantAwareDbContext( { if (entry.State == EntityState.Added && entry.Entity.TenantId == 0 && tenantId != 0) { - entry.Entity.TenantId = tenantId; + if (!IsPlatformAdmin()) + { + entry.Entity.TenantId = tenantId; + } } } } + + private bool IsPlatformAdmin() + { + var user = _httpContextAccessor?.HttpContext?.User; + if (user?.Identity?.IsAuthenticated != true) + { + return false; + } + + return PlatformRoleCodes.Any(user.IsInRole); + } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs index 3c53f2f..1db973a 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Extensions/DictionaryServiceCollectionExtensions.cs @@ -39,6 +39,7 @@ public static class DictionaryServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs index c840feb..0ff9fff 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Persistence/DictionaryDbContext.cs @@ -7,6 +7,7 @@ using TakeoutSaaS.Infrastructure.Common.Persistence; using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; +using Microsoft.AspNetCore.Http; namespace TakeoutSaaS.Infrastructure.Dictionary.Persistence; @@ -17,8 +18,9 @@ public sealed class DictionaryDbContext( DbContextOptions options, ITenantProvider tenantProvider, ICurrentUserAccessor? currentUserAccessor = null, - IIdGenerator? idGenerator = null) - : TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator) + IIdGenerator? idGenerator = null, + IHttpContextAccessor? httpContextAccessor = null) + : TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor) { /// /// 字典分组集合。 @@ -45,6 +47,11 @@ public sealed class DictionaryDbContext( /// public DbSet CacheInvalidationLogs => Set(); + /// + /// 字典标签覆盖集合。 + /// + public DbSet DictionaryLabelOverrides => Set(); + /// /// 系统参数集合。 /// @@ -62,6 +69,7 @@ public sealed class DictionaryDbContext( ConfigureGroup(modelBuilder.Entity(), isSqlite); ConfigureItem(modelBuilder.Entity(), isSqlite); ConfigureOverride(modelBuilder.Entity()); + ConfigureLabelOverride(modelBuilder.Entity()); ConfigureImportLog(modelBuilder.Entity()); ConfigureCacheInvalidationLog(modelBuilder.Entity()); ConfigureSystemParameter(modelBuilder.Entity()); @@ -228,4 +236,34 @@ public sealed class DictionaryDbContext( builder.HasIndex(x => x.TenantId); builder.HasIndex(x => new { x.TenantId, x.Key }).IsUnique(); } + + /// + /// 配置字典标签覆盖。 + /// + /// 实体构建器。 + private static void ConfigureLabelOverride(EntityTypeBuilder builder) + { + builder.ToTable("dictionary_label_overrides", t => t.HasComment("字典标签覆盖配置。")); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).HasComment("实体唯一标识。"); + builder.Property(x => x.TenantId).IsRequired().HasComment("所属租户 ID(覆盖目标租户)。"); + builder.Property(x => x.DictionaryItemId).IsRequired().HasComment("被覆盖的字典项 ID。"); + builder.Property(x => x.OriginalValue).HasColumnType("jsonb").IsRequired().HasComment("原始显示值(JSON 格式,多语言)。"); + builder.Property(x => x.OverrideValue).HasColumnType("jsonb").IsRequired().HasComment("覆盖后的显示值(JSON 格式,多语言)。"); + builder.Property(x => x.OverrideType).HasConversion().IsRequired().HasComment("覆盖类型。"); + builder.Property(x => x.Reason).HasMaxLength(512).HasComment("覆盖原因/备注。"); + ConfigureAuditableEntity(builder); + ConfigureSoftDeleteEntity(builder); + + builder.HasOne(x => x.DictionaryItem) + .WithMany() + .HasForeignKey(x => x.DictionaryItemId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => new { x.TenantId, x.DictionaryItemId }) + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + builder.HasIndex(x => new { x.TenantId, x.OverrideType }); + } } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryGroupRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryGroupRepository.cs index df38382..a3abdc0 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryGroupRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryGroupRepository.cs @@ -155,9 +155,15 @@ public sealed class DictionaryGroupRepository(DictionaryDbContext context) : IDi if (!string.IsNullOrWhiteSpace(keyword)) { var trimmed = keyword.Trim(); - query = query.Where(group => - EF.Property(group, "Code").Contains(trimmed) || - group.Name.Contains(trimmed)); + if (DictionaryCode.IsValid(trimmed)) + { + var code = new DictionaryCode(trimmed); + query = query.Where(group => group.Code == code || group.Name.Contains(trimmed)); + } + else + { + query = query.Where(group => group.Name.Contains(trimmed)); + } } if (isEnabled.HasValue) diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryLabelOverrideRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryLabelOverrideRepository.cs new file mode 100644 index 0000000..eefccbe --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Dictionary/Repositories/DictionaryLabelOverrideRepository.cs @@ -0,0 +1,115 @@ +using Microsoft.EntityFrameworkCore; +using TakeoutSaaS.Domain.Dictionary.Entities; +using TakeoutSaaS.Domain.Dictionary.Enums; +using TakeoutSaaS.Domain.Dictionary.Repositories; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +namespace TakeoutSaaS.Infrastructure.Dictionary.Repositories; + +/// +/// 字典标签覆盖仓储实现。 +/// +public sealed class DictionaryLabelOverrideRepository(DictionaryDbContext context) : IDictionaryLabelOverrideRepository +{ + /// + /// 根据 ID 获取覆盖配置。 + /// + public Task GetByIdAsync(long id, CancellationToken cancellationToken = default) + { + return context.DictionaryLabelOverrides + .IgnoreQueryFilters() + .Include(x => x.DictionaryItem) + .FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken); + } + + /// + /// 获取指定字典项的覆盖配置。 + /// + public Task GetByItemIdAsync(long tenantId, long dictionaryItemId, CancellationToken cancellationToken = default) + { + return context.DictionaryLabelOverrides + .IgnoreQueryFilters() + .FirstOrDefaultAsync(x => + x.TenantId == tenantId && + x.DictionaryItemId == dictionaryItemId && + x.DeletedAt == null, + cancellationToken); + } + + /// + /// 获取租户的所有覆盖配置。 + /// + public async Task> ListByTenantAsync( + long tenantId, + OverrideType? overrideType = null, + CancellationToken cancellationToken = default) + { + var query = context.DictionaryLabelOverrides + .AsNoTracking() + .IgnoreQueryFilters() + .Include(x => x.DictionaryItem) + .Where(x => x.TenantId == tenantId && x.DeletedAt == null); + + if (overrideType.HasValue) + { + query = query.Where(x => x.OverrideType == overrideType.Value); + } + + return await query.OrderByDescending(x => x.CreatedAt).ToListAsync(cancellationToken); + } + + /// + /// 批量获取多个字典项的覆盖配置。 + /// + public async Task> GetByItemIdsAsync( + long tenantId, + IEnumerable dictionaryItemIds, + CancellationToken cancellationToken = default) + { + var ids = dictionaryItemIds.ToArray(); + if (ids.Length == 0) return Array.Empty(); + + return await context.DictionaryLabelOverrides + .AsNoTracking() + .IgnoreQueryFilters() + .Where(x => + x.TenantId == tenantId && + ids.Contains(x.DictionaryItemId) && + x.DeletedAt == null) + .ToListAsync(cancellationToken); + } + + /// + /// 新增覆盖配置。 + /// + public Task AddAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default) + { + context.DictionaryLabelOverrides.Add(entity); + return Task.CompletedTask; + } + + /// + /// 更新覆盖配置。 + /// + public Task UpdateAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default) + { + context.DictionaryLabelOverrides.Update(entity); + return Task.CompletedTask; + } + + /// + /// 删除覆盖配置。 + /// + public Task DeleteAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default) + { + entity.DeletedAt = DateTime.UtcNow; + context.DictionaryLabelOverrides.Update(entity); + return Task.CompletedTask; + } + + /// + /// 持久化更改。 + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => context.SaveChangesAsync(cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs index 9d0467b..33656c8 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -6,6 +6,7 @@ using TakeoutSaaS.Infrastructure.Common.Persistence; using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; +using Microsoft.AspNetCore.Http; namespace TakeoutSaaS.Infrastructure.Identity.Persistence; @@ -16,8 +17,9 @@ public sealed class IdentityDbContext( DbContextOptions options, ITenantProvider tenantProvider, ICurrentUserAccessor? currentUserAccessor = null, - IIdGenerator? idGenerator = null) - : TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator) + IIdGenerator? idGenerator = null, + IHttpContextAccessor? httpContextAccessor = null) + : TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor) { /// /// 管理后台用户集合。 diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/TakeoutLogsDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/TakeoutLogsDbContext.cs index a34444d..37918e4 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/TakeoutLogsDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Persistence/TakeoutLogsDbContext.cs @@ -7,6 +7,7 @@ using TakeoutSaaS.Infrastructure.Common.Persistence; using TakeoutSaaS.Shared.Abstractions.Ids; using TakeoutSaaS.Shared.Abstractions.Security; using TakeoutSaaS.Shared.Abstractions.Tenancy; +using Microsoft.AspNetCore.Http; namespace TakeoutSaaS.Infrastructure.Logs.Persistence; @@ -17,8 +18,9 @@ public sealed class TakeoutLogsDbContext( DbContextOptions options, ITenantProvider tenantProvider, ICurrentUserAccessor? currentUserAccessor = null, - IIdGenerator? idGenerator = null) - : TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator) + IIdGenerator? idGenerator = null, + IHttpContextAccessor? httpContextAccessor = null) + : TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator, httpContextAccessor) { /// /// 租户审计日志集合。 diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230044727_UpdateDictionarySchema.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230044727_UpdateDictionarySchema.cs index 1c15b5b..6045021 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230044727_UpdateDictionarySchema.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230044727_UpdateDictionarySchema.cs @@ -20,16 +20,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb name: "IX_dictionary_groups_TenantId_Code", table: "dictionary_groups"); - migrationBuilder.AlterColumn( - name: "Value", - table: "dictionary_items", - type: "jsonb", - nullable: false, - comment: "字典项值。", - oldClrType: typeof(string), - oldType: "character varying(256)", - oldMaxLength: 256, - oldComment: "字典项值。"); + // 使用原生 SQL 进行类型转换,确保现有数据被正确转换为 JSONB + migrationBuilder.Sql( + """ + ALTER TABLE dictionary_items + ALTER COLUMN "Value" TYPE jsonb + USING to_jsonb("Value"::text); + + COMMENT ON COLUMN dictionary_items."Value" IS '字典项值。'; + """); migrationBuilder.AlterColumn( name: "Key", diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230140335_AddCacheInvalidationLogs.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230140335_AddCacheInvalidationLogs.Designer.cs new file mode 100644 index 0000000..50fd6bb --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230140335_AddCacheInvalidationLogs.Designer.cs @@ -0,0 +1,544 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb +{ + [DbContext(typeof(DictionaryDbContext))] + [Migration("20251230140335_AddCacheInvalidationLogs")] + partial class AddCacheInvalidationLogs + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.CacheInvalidationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffectedCacheKeyCount") + .HasColumnType("integer") + .HasComment("影响的缓存键数量。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DictionaryCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("字典编码。"); + + b.Property("Operation") + .HasColumnType("integer") + .HasComment("操作类型。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人用户标识。"); + + b.Property("Scope") + .HasColumnType("integer") + .HasComment("字典作用域。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间(UTC)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Timestamp"); + + b.ToTable("dictionary_cache_invalidation_logs", null, t => + { + t.HasComment("字典缓存失效日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowOverride") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否允许租户覆盖。"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("分组名称。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("Scope") + .HasColumnType("integer") + .HasComment("分组作用域:系统/业务。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.HasIndex("TenantId", "Scope", "IsEnabled"); + + b.ToTable("dictionary_groups", null, t => + { + t.HasComment("参数字典分组(系统参数、业务参数)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryImportLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConflictMode") + .HasColumnType("integer") + .HasComment("冲突处理模式。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DictionaryGroupCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("字典分组编码。"); + + b.Property("Duration") + .HasColumnType("interval") + .HasComment("处理耗时。"); + + b.Property("ErrorDetails") + .HasColumnType("jsonb") + .HasComment("错误明细(JSON)。"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("导入文件名。"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasComment("文件大小(字节)。"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("文件格式(CSV/JSON)。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人用户标识。"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasComment("处理时间(UTC)。"); + + b.Property("SkipCount") + .HasColumnType("integer") + .HasComment("跳过数量。"); + + b.Property("SuccessCount") + .HasColumnType("integer") + .HasComment("成功导入数量。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ProcessedAt"); + + b.ToTable("dictionary_import_logs", null, t => + { + t.HasComment("字典导入审计日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("GroupId") + .HasColumnType("bigint") + .HasComment("关联分组 ID。"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasComment("是否默认项。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("字典项键。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasComment("排序值,越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("字典项值。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("GroupId", "IsEnabled", "SortOrder"); + + b.HasIndex("TenantId", "GroupId", "Key") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.ToTable("dictionary_items", null, t => + { + t.HasComment("参数字典项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.TenantDictionaryOverride", b => + { + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("SystemDictionaryGroupId") + .HasColumnType("bigint") + .HasComment("系统字典分组 ID。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识。"); + + b.Property("CustomSortOrder") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("自定义排序映射(JSON)。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("删除时间(UTC)。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识。"); + + b.PrimitiveCollection("HiddenSystemItemIds") + .IsRequired() + .HasColumnType("bigint[]") + .HasComment("隐藏的系统字典项 ID 列表。"); + + b.Property("OverrideEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否启用覆盖。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近更新时间(UTC)。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识。"); + + b.HasKey("TenantId", "SystemDictionaryGroupId"); + + b.HasIndex("HiddenSystemItemIds"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("HiddenSystemItemIds"), "gin"); + + b.ToTable("tenant_dictionary_overrides", null, t => + { + t.HasComment("租户字典覆盖配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.SystemParameters.Entities.SystemParameter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("参数键,租户内唯一。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasComment("排序值,越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasComment("参数值,支持文本或 JSON。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Key") + .IsUnique(); + + b.ToTable("system_parameters", null, t => + { + t.HasComment("系统参数实体:支持按租户维护的键值型配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b => + { + b.HasOne("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", "Group") + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230162000_AddCacheInvalidationLogs.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230140335_AddCacheInvalidationLogs.cs similarity index 99% rename from src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230162000_AddCacheInvalidationLogs.cs rename to src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230140335_AddCacheInvalidationLogs.cs index c0d3556..cafa46c 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230162000_AddCacheInvalidationLogs.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230140335_AddCacheInvalidationLogs.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230170000_AddDictionaryRowVersionTriggers.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230170000_AddDictionaryRowVersionTriggers.cs new file mode 100644 index 0000000..076fc33 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230170000_AddDictionaryRowVersionTriggers.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb +{ + /// + /// 为字典表添加 RowVersion 自动生成触发器。 + /// + [DbContext(typeof(DictionaryDbContext))] + [Migration("20251230170000_AddDictionaryRowVersionTriggers")] + public partial class AddDictionaryRowVersionTriggers : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1. 创建通用的 RowVersion 生成函数 + migrationBuilder.Sql( + """ + CREATE OR REPLACE FUNCTION public.set_dictionary_row_version() + RETURNS trigger AS $$ + BEGIN + NEW."RowVersion" = decode(md5(random()::text || clock_timestamp()::text), 'hex'); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """); + + // 2. 为 dictionary_groups 表创建触发器 + migrationBuilder.Sql( + """ + DROP TRIGGER IF EXISTS trg_dictionary_groups_row_version ON dictionary_groups; + CREATE TRIGGER trg_dictionary_groups_row_version + BEFORE INSERT OR UPDATE ON dictionary_groups + FOR EACH ROW EXECUTE FUNCTION public.set_dictionary_row_version(); + """); + + // 3. 为 dictionary_items 表创建触发器 + migrationBuilder.Sql( + """ + DROP TRIGGER IF EXISTS trg_dictionary_items_row_version ON dictionary_items; + CREATE TRIGGER trg_dictionary_items_row_version + BEFORE INSERT OR UPDATE ON dictionary_items + FOR EACH ROW EXECUTE FUNCTION public.set_dictionary_row_version(); + """); + + // 4. 回填现有 dictionary_groups 数据的 RowVersion + migrationBuilder.Sql( + """ + UPDATE dictionary_groups + SET "RowVersion" = decode(md5(random()::text || clock_timestamp()::text), 'hex') + WHERE "RowVersion" IS NULL OR octet_length("RowVersion") = 0; + """); + + // 5. 回填现有 dictionary_items 数据的 RowVersion + migrationBuilder.Sql( + """ + UPDATE dictionary_items + SET "RowVersion" = decode(md5(random()::text || clock_timestamp()::text), 'hex') + WHERE "RowVersion" IS NULL OR octet_length("RowVersion") = 0; + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DROP TRIGGER IF EXISTS trg_dictionary_groups_row_version ON dictionary_groups; + DROP TRIGGER IF EXISTS trg_dictionary_items_row_version ON dictionary_items; + DROP FUNCTION IF EXISTS public.set_dictionary_row_version(); + """); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230232516_AddDictionaryLabelOverrides.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230232516_AddDictionaryLabelOverrides.Designer.cs new file mode 100644 index 0000000..621bacd --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230232516_AddDictionaryLabelOverrides.Designer.cs @@ -0,0 +1,633 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.Dictionary.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb +{ + [DbContext(typeof(DictionaryDbContext))] + [Migration("20251230232516_AddDictionaryLabelOverrides")] + partial class AddDictionaryLabelOverrides + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.CacheInvalidationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffectedCacheKeyCount") + .HasColumnType("integer") + .HasComment("影响的缓存键数量。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DictionaryCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("字典编码。"); + + b.Property("Operation") + .HasColumnType("integer") + .HasComment("操作类型。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人用户标识。"); + + b.Property("Scope") + .HasColumnType("integer") + .HasComment("字典作用域。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间(UTC)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Timestamp"); + + b.ToTable("dictionary_cache_invalidation_logs", null, t => + { + t.HasComment("字典缓存失效日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowOverride") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否允许租户覆盖。"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("分组名称。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("Scope") + .HasColumnType("integer") + .HasComment("分组作用域:系统/业务。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.HasIndex("TenantId", "Scope", "IsEnabled"); + + b.ToTable("dictionary_groups", null, t => + { + t.HasComment("参数字典分组(系统参数、业务参数)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryImportLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConflictMode") + .HasColumnType("integer") + .HasComment("冲突处理模式。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DictionaryGroupCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("字典分组编码。"); + + b.Property("Duration") + .HasColumnType("interval") + .HasComment("处理耗时。"); + + b.Property("ErrorDetails") + .HasColumnType("jsonb") + .HasComment("错误明细(JSON)。"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("导入文件名。"); + + b.Property("FileSize") + .HasColumnType("bigint") + .HasComment("文件大小(字节)。"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("文件格式(CSV/JSON)。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人用户标识。"); + + b.Property("ProcessedAt") + .HasColumnType("timestamp with time zone") + .HasComment("处理时间(UTC)。"); + + b.Property("SkipCount") + .HasColumnType("integer") + .HasComment("跳过数量。"); + + b.Property("SuccessCount") + .HasColumnType("integer") + .HasComment("成功导入数量。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ProcessedAt"); + + b.ToTable("dictionary_import_logs", null, t => + { + t.HasComment("字典导入审计日志。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("GroupId") + .HasColumnType("bigint") + .HasComment("关联分组 ID。"); + + b.Property("IsDefault") + .HasColumnType("boolean") + .HasComment("是否默认项。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("字典项键。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasComment("排序值,越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Value") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("字典项值。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("GroupId", "IsEnabled", "SortOrder"); + + b.HasIndex("TenantId", "GroupId", "Key") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.ToTable("dictionary_items", null, t => + { + t.HasComment("参数字典项。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryLabelOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("删除时间(UTC)。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识。"); + + b.Property("DictionaryItemId") + .HasColumnType("bigint") + .HasComment("被覆盖的字典项 ID。"); + + b.Property("OriginalValue") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("原始显示值(JSON 格式,多语言)。"); + + b.Property("OverrideType") + .HasColumnType("integer") + .HasComment("覆盖类型。"); + + b.Property("OverrideValue") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("覆盖后的显示值(JSON 格式,多语言)。"); + + b.Property("Reason") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("覆盖原因/备注。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID(覆盖目标租户)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近更新时间(UTC)。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("DictionaryItemId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "DictionaryItemId") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.HasIndex("TenantId", "OverrideType"); + + b.ToTable("dictionary_label_overrides", null, t => + { + t.HasComment("字典标签覆盖配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.TenantDictionaryOverride", b => + { + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("SystemDictionaryGroupId") + .HasColumnType("bigint") + .HasComment("系统字典分组 ID。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识。"); + + b.Property("CustomSortOrder") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("自定义排序映射(JSON)。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("删除时间(UTC)。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识。"); + + b.PrimitiveCollection("HiddenSystemItemIds") + .IsRequired() + .HasColumnType("bigint[]") + .HasComment("隐藏的系统字典项 ID 列表。"); + + b.Property("OverrideEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasComment("是否启用覆盖。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近更新时间(UTC)。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识。"); + + b.HasKey("TenantId", "SystemDictionaryGroupId"); + + b.HasIndex("HiddenSystemItemIds"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("HiddenSystemItemIds"), "gin"); + + b.ToTable("tenant_dictionary_overrides", null, t => + { + t.HasComment("租户字典覆盖配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.SystemParameters.Entities.SystemParameter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("描述信息。"); + + b.Property("IsEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasComment("是否启用。"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("参数键,租户内唯一。"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(100) + .HasComment("排序值,越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasComment("参数值,支持文本或 JSON。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Key") + .IsUnique(); + + b.ToTable("system_parameters", null, t => + { + t.HasComment("系统参数实体:支持按租户维护的键值型配置。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b => + { + b.HasOne("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", "Group") + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryLabelOverride", b => + { + b.HasOne("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", "DictionaryItem") + .WithMany() + .HasForeignKey("DictionaryItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DictionaryItem"); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230232516_AddDictionaryLabelOverrides.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230232516_AddDictionaryLabelOverrides.cs new file mode 100644 index 0000000..51687b4 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/20251230232516_AddDictionaryLabelOverrides.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb +{ + /// + public partial class AddDictionaryLabelOverrides : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "dictionary_label_overrides", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false, comment: "实体唯一标识。") + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + TenantId = table.Column(type: "bigint", nullable: false, comment: "所属租户 ID(覆盖目标租户)。"), + DictionaryItemId = table.Column(type: "bigint", nullable: false, comment: "被覆盖的字典项 ID。"), + OriginalValue = table.Column(type: "jsonb", nullable: false, comment: "原始显示值(JSON 格式,多语言)。"), + OverrideValue = table.Column(type: "jsonb", nullable: false, comment: "覆盖后的显示值(JSON 格式,多语言)。"), + OverrideType = table.Column(type: "integer", nullable: false, comment: "覆盖类型。"), + Reason = table.Column(type: "character varying(512)", maxLength: 512, nullable: true, comment: "覆盖原因/备注。"), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "最近更新时间(UTC)。"), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true, comment: "删除时间(UTC)。"), + CreatedBy = table.Column(type: "bigint", nullable: true, comment: "创建人用户标识。"), + UpdatedBy = table.Column(type: "bigint", nullable: true, comment: "最后更新人用户标识。"), + DeletedBy = table.Column(type: "bigint", nullable: true, comment: "删除人用户标识。") + }, + constraints: table => + { + table.PrimaryKey("PK_dictionary_label_overrides", x => x.Id); + table.ForeignKey( + name: "FK_dictionary_label_overrides_dictionary_items_DictionaryItemId", + column: x => x.DictionaryItemId, + principalTable: "dictionary_items", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }, + comment: "字典标签覆盖配置。"); + + migrationBuilder.CreateIndex( + name: "IX_dictionary_label_overrides_DictionaryItemId", + table: "dictionary_label_overrides", + column: "DictionaryItemId"); + + migrationBuilder.CreateIndex( + name: "IX_dictionary_label_overrides_TenantId", + table: "dictionary_label_overrides", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_dictionary_label_overrides_TenantId_DictionaryItemId", + table: "dictionary_label_overrides", + columns: new[] { "TenantId", "DictionaryItemId" }, + unique: true, + filter: "\"DeletedAt\" IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_dictionary_label_overrides_TenantId_OverrideType", + table: "dictionary_label_overrides", + columns: new[] { "TenantId", "OverrideType" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "dictionary_label_overrides"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/DictionaryDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/DictionaryDbContextModelSnapshot.cs index 59c7f42..bda7474 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/DictionaryDbContextModelSnapshot.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/DictionaryDb/DictionaryDbContextModelSnapshot.cs @@ -22,6 +22,79 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.CacheInvalidationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AffectedCacheKeyCount") + .HasColumnType("integer") + .HasComment("影响的缓存键数量。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DictionaryCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("字典编码。"); + + b.Property("Operation") + .HasColumnType("integer") + .HasComment("操作类型。"); + + b.Property("OperatorId") + .HasColumnType("bigint") + .HasComment("操作人用户标识。"); + + b.Property("Scope") + .HasColumnType("integer") + .HasComment("字典作用域。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone") + .HasComment("发生时间(UTC)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "Timestamp"); + + b.ToTable("dictionary_cache_invalidation_logs", null, t => + { + t.HasComment("字典缓存失效日志。"); + }); + }); + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b => { b.Property("Id") @@ -211,79 +284,6 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb }); }); - modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.CacheInvalidationLog", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("bigint") - .HasComment("实体唯一标识。"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("AffectedCacheKeyCount") - .HasColumnType("integer") - .HasComment("影响的缓存键数量。"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone") - .HasComment("创建时间(UTC)。"); - - b.Property("CreatedBy") - .HasColumnType("bigint") - .HasComment("创建人用户标识,匿名或系统操作时为 null。"); - - b.Property("DictionaryCode") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)") - .HasComment("字典编码。"); - - b.Property("DeletedAt") - .HasColumnType("timestamp with time zone") - .HasComment("软删除时间(UTC),未删除时为 null。"); - - b.Property("DeletedBy") - .HasColumnType("bigint") - .HasComment("删除人用户标识(软删除),未删除时为 null。"); - - b.Property("Operation") - .HasColumnType("integer") - .HasComment("操作类型。"); - - b.Property("OperatorId") - .HasColumnType("bigint") - .HasComment("操作人用户标识。"); - - b.Property("Scope") - .HasColumnType("integer") - .HasComment("字典作用域。"); - - b.Property("TenantId") - .HasColumnType("bigint") - .HasComment("所属租户 ID。"); - - b.Property("Timestamp") - .HasColumnType("timestamp with time zone") - .HasComment("发生时间(UTC)。"); - - b.Property("UpdatedAt") - .HasColumnType("timestamp with time zone") - .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); - - b.Property("UpdatedBy") - .HasColumnType("bigint") - .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); - - b.HasKey("Id"); - - b.HasIndex("TenantId", "Timestamp"); - - b.ToTable("dictionary_cache_invalidation_logs", null, t => - { - t.HasComment("字典缓存失效日志。"); - }); - }); - modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", b => { b.Property("Id") @@ -380,6 +380,84 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb }); }); + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryLabelOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("删除时间(UTC)。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识。"); + + b.Property("DictionaryItemId") + .HasColumnType("bigint") + .HasComment("被覆盖的字典项 ID。"); + + b.Property("OriginalValue") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("原始显示值(JSON 格式,多语言)。"); + + b.Property("OverrideType") + .HasColumnType("integer") + .HasComment("覆盖类型。"); + + b.Property("OverrideValue") + .IsRequired() + .HasColumnType("jsonb") + .HasComment("覆盖后的显示值(JSON 格式,多语言)。"); + + b.Property("Reason") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("覆盖原因/备注。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID(覆盖目标租户)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近更新时间(UTC)。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识。"); + + b.HasKey("Id"); + + b.HasIndex("DictionaryItemId"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "DictionaryItemId") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.HasIndex("TenantId", "OverrideType"); + + b.ToTable("dictionary_label_overrides", null, t => + { + t.HasComment("字典标签覆盖配置。"); + }); + }); + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.TenantDictionaryOverride", b => { b.Property("TenantId") @@ -531,6 +609,17 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb b.Navigation("Group"); }); + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryLabelOverride", b => + { + b.HasOne("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryItem", "DictionaryItem") + .WithMany() + .HasForeignKey("DictionaryItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DictionaryItem"); + }); + modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryGroup", b => { b.Navigation("Items");