feat: 提交后端其余改动
This commit is contained in:
@@ -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<TenantAnnouncement>)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<T>`(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
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -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[完成]
|
||||
```
|
||||
@@ -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`。
|
||||
@@ -1,314 +0,0 @@
|
||||
# 公告管理 API 文档
|
||||
|
||||
> 最后更新日期:2025-12-20
|
||||
|
||||
本文档覆盖公告管理相关 API,包括平台公告、租户公告管理端接口,以及应用端(已认证用户)接口。
|
||||
|
||||
## 统一约定
|
||||
|
||||
- 认证方式:`Authorization: Bearer <JWT>`
|
||||
- 时间字段均为 UTC(ISO 8601)。
|
||||
- 雪花 ID 以字符串形式序列化返回。
|
||||
- 统一响应结构:`ApiResponse<T>`。
|
||||
|
||||
```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<TenantAnnouncementDto>`
|
||||
- **错误码**: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<PagedResult<TenantAnnouncementDto>>`
|
||||
- **错误码**:403
|
||||
|
||||
示例:`GET /api/platform/announcements?page=1&pageSize=20&status=Published`
|
||||
|
||||
### 3) 获取平台公告详情
|
||||
- **方法**:GET
|
||||
- **路径**:`/api/platform/announcements/{announcementId}`
|
||||
- **权限**:`platform-announcement:create`
|
||||
- **响应**:`ApiResponse<TenantAnnouncementDto>`
|
||||
- **错误码**:403 / 404
|
||||
|
||||
### 4) 更新平台公告(仅草稿)
|
||||
- **方法**:PUT
|
||||
- **路径**:`/api/platform/announcements/{announcementId}`
|
||||
- **权限**:`platform-announcement:create`
|
||||
- **请求体**:`UpdateTenantAnnouncementCommand`
|
||||
- **响应**:`ApiResponse<TenantAnnouncementDto>`
|
||||
- **错误码**: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<TenantAnnouncementDto>`
|
||||
- **错误码**:403 / 404 / 409
|
||||
|
||||
请求示例:
|
||||
```json
|
||||
{ "rowVersion": "AAAAAAAAB9E=" }
|
||||
```
|
||||
|
||||
### 6) 撤销平台公告
|
||||
- **方法**:POST
|
||||
- **路径**:`/api/platform/announcements/{announcementId}/revoke`
|
||||
- **权限**:`platform-announcement:revoke`
|
||||
- **请求体**:`RevokeAnnouncementCommand`
|
||||
- **响应**:`ApiResponse<TenantAnnouncementDto>`
|
||||
- **错误码**: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<PagedResult<TenantAnnouncementDto>>`
|
||||
- **错误码**:403
|
||||
|
||||
### 2) 获取租户公告详情
|
||||
- **方法**:GET
|
||||
- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements/{announcementId}`
|
||||
- **权限**:`tenant-announcement:read`
|
||||
- **响应**:`ApiResponse<TenantAnnouncementDto>`
|
||||
- **错误码**:403 / 404
|
||||
|
||||
### 3) 创建租户公告
|
||||
- **方法**:POST
|
||||
- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements`
|
||||
- **权限**:`tenant-announcement:create`
|
||||
- **请求体**:`CreateTenantAnnouncementCommand`
|
||||
- **响应**:`ApiResponse<TenantAnnouncementDto>`
|
||||
- **错误码**: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<TenantAnnouncementDto>`
|
||||
- **错误码**:403 / 404 / 409
|
||||
|
||||
### 5) 发布租户公告
|
||||
- **方法**:POST
|
||||
- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements/{announcementId}/publish`
|
||||
- **权限**:`tenant-announcement:publish`
|
||||
- **请求体**:`PublishAnnouncementCommand`
|
||||
- **响应**:`ApiResponse<TenantAnnouncementDto>`
|
||||
- **错误码**:403 / 404 / 409
|
||||
|
||||
### 6) 撤销租户公告
|
||||
- **方法**:POST
|
||||
- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements/{announcementId}/revoke`
|
||||
- **权限**:`tenant-announcement:revoke`
|
||||
- **请求体**:`RevokeAnnouncementCommand`
|
||||
- **响应**:`ApiResponse<TenantAnnouncementDto>`
|
||||
- **错误码**:403 / 404 / 409
|
||||
|
||||
### 7) 删除租户公告
|
||||
- **方法**:DELETE
|
||||
- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements/{announcementId}`
|
||||
- **权限**:`tenant-announcement:delete`
|
||||
- **响应**:`ApiResponse<bool>`
|
||||
- **错误码**:403
|
||||
|
||||
### 8) 标记公告已读(兼容旧路径)
|
||||
- **方法**:POST
|
||||
- **路径**:`/api/admin/v1/tenants/{tenantId}/announcements/{announcementId}/read`
|
||||
- **权限**:`tenant-announcement:read`
|
||||
- **响应**:`ApiResponse<TenantAnnouncementDto>`
|
||||
- **错误码**:403 / 404
|
||||
|
||||
---
|
||||
|
||||
# 应用端公告 API(已认证用户)
|
||||
|
||||
> 路由前缀:`/api/app/announcements`(目前挂载在 AdminApi)
|
||||
|
||||
### 1) 获取可见公告列表
|
||||
- **方法**:GET
|
||||
- **路径**:`/api/app/announcements`
|
||||
- **权限**:登录即可
|
||||
- **查询参数**:`page` / `pageSize`(其他筛选参数会被覆盖为已发布与有效期内)
|
||||
- **响应**:`ApiResponse<PagedResult<TenantAnnouncementDto>>`
|
||||
- **错误码**:401
|
||||
|
||||
### 2) 获取未读公告列表
|
||||
- **方法**:GET
|
||||
- **路径**:`/api/app/announcements/unread`
|
||||
- **权限**:登录即可
|
||||
- **查询参数**:`page` / `pageSize`
|
||||
- **响应**:`ApiResponse<PagedResult<TenantAnnouncementDto>>`
|
||||
- **错误码**:401
|
||||
|
||||
### 3) 标记公告已读
|
||||
- **方法**:POST
|
||||
- **路径**:`/api/app/announcements/{announcementId}/mark-read`
|
||||
- **权限**:登录即可
|
||||
- **请求体**:无
|
||||
- **响应**:`ApiResponse<TenantAnnouncementDto>`
|
||||
- **错误码**:401 / 404
|
||||
|
||||
---
|
||||
|
||||
## 常见错误码
|
||||
|
||||
- **400**:参数验证失败
|
||||
- **401**:未认证
|
||||
- **403**:无权限
|
||||
- **404**:公告不存在或不可见
|
||||
- **409**:状态冲突(例如已发布不可编辑)
|
||||
|
||||
> 提示:实际错误码与消息由 `BusinessException` 和中间件统一返回。
|
||||
@@ -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[发布]
|
||||
```
|
||||
|
||||
> 建议在生产环境迁移前进行全量备份,并在灰度环境验证数据一致性。
|
||||
@@ -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`。
|
||||
@@ -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');
|
||||
```
|
||||
|
||||
> 建议在权限变更后刷新相关缓存或重新登录以生效。
|
||||
@@ -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();
|
||||
```
|
||||
|
||||
> 如需补充更多技术债务,请在此文件追加条目并注明日期。
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 字典标签覆盖管理。
|
||||
/// </summary>
|
||||
[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(租户覆盖系统字典)
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前租户的标签覆盖列表。
|
||||
/// </summary>
|
||||
[HttpGet("tenant")]
|
||||
[PermissionAuthorize("dictionary:override:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<LabelOverrideDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<LabelOverrideDto>>> ListTenantOverrides(
|
||||
[FromQuery] OverrideType? overrideType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var headerError = EnsureTenantHeader<IReadOnlyList<LabelOverrideDto>>();
|
||||
if (headerError != null)
|
||||
{
|
||||
return headerError;
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var result = await labelOverrideService.GetOverridesAsync(tenantId, overrideType, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<LabelOverrideDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 租户覆盖系统字典项的标签。
|
||||
/// </summary>
|
||||
[HttpPost("tenant")]
|
||||
[PermissionAuthorize("dictionary:override:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<LabelOverrideDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<LabelOverrideDto>> CreateTenantOverride(
|
||||
[FromBody] UpsertLabelOverrideRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var headerError = EnsureTenantHeader<LabelOverrideDto>();
|
||||
if (headerError != null)
|
||||
{
|
||||
return headerError;
|
||||
}
|
||||
|
||||
var tenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorId = currentUserAccessor.UserId;
|
||||
var result = await labelOverrideService.UpsertTenantOverrideAsync(tenantId, request, operatorId, cancellationToken);
|
||||
return ApiResponse<LabelOverrideDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 租户删除自己的标签覆盖。
|
||||
/// </summary>
|
||||
[HttpDelete("tenant/{dictionaryItemId:long}")]
|
||||
[PermissionAuthorize("dictionary:override:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> DeleteTenantOverride(long dictionaryItemId, CancellationToken cancellationToken)
|
||||
{
|
||||
var headerError = EnsureTenantHeader<object>();
|
||||
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(平台管理所有租户的覆盖)
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定租户的所有标签覆盖(平台管理员用)。
|
||||
/// </summary>
|
||||
[HttpGet("platform/{targetTenantId:long}")]
|
||||
[PermissionAuthorize("dictionary:override:platform:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<LabelOverrideDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<IReadOnlyList<LabelOverrideDto>>> ListPlatformOverrides(
|
||||
long targetTenantId,
|
||||
[FromQuery] OverrideType? overrideType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await labelOverrideService.GetOverridesAsync(targetTenantId, overrideType, cancellationToken);
|
||||
return ApiResponse<IReadOnlyList<LabelOverrideDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 平台强制覆盖租户字典项的标签。
|
||||
/// </summary>
|
||||
[HttpPost("platform/{targetTenantId:long}")]
|
||||
[PermissionAuthorize("dictionary:override:platform:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<LabelOverrideDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<LabelOverrideDto>> CreatePlatformOverride(
|
||||
long targetTenantId,
|
||||
[FromBody] UpsertLabelOverrideRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var operatorId = currentUserAccessor.UserId;
|
||||
var result = await labelOverrideService.UpsertPlatformOverrideAsync(targetTenantId, request, operatorId, cancellationToken);
|
||||
return ApiResponse<LabelOverrideDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 平台删除对租户的强制覆盖。
|
||||
/// </summary>
|
||||
[HttpDelete("platform/{targetTenantId:long}/{dictionaryItemId:long}")]
|
||||
[PermissionAuthorize("dictionary:override:platform:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> 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<T>? EnsureTenantHeader<T>()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(TenantIdHeaderName, out var tenantHeader) || string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
return ApiResponse<T>.Error(StatusCodes.Status400BadRequest, $"缺少租户标识,请在请求头 {TenantIdHeaderName} 指定租户");
|
||||
}
|
||||
|
||||
if (!long.TryParse(tenantHeader.FirstOrDefault(), out _))
|
||||
{
|
||||
return ApiResponse<T>.Error(StatusCodes.Status400BadRequest, $"租户标识无效,请在请求头 {TenantIdHeaderName} 指定正确的租户 ID");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public static class DictionaryServiceCollectionExtensions
|
||||
services.AddScoped<DictionaryQueryService>();
|
||||
services.AddScoped<DictionaryMergeService>();
|
||||
services.AddScoped<DictionaryOverrideService>();
|
||||
services.AddScoped<DictionaryLabelOverrideService>();
|
||||
services.AddScoped<DictionaryImportExportService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.Dictionary.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 字典标签覆盖 DTO。
|
||||
/// </summary>
|
||||
public sealed class LabelOverrideDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 覆盖记录 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 被覆盖的字典项 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long DictionaryItemId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 字典项 Key。
|
||||
/// </summary>
|
||||
public string DictionaryItemKey { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 原始显示值(多语言)。
|
||||
/// </summary>
|
||||
public Dictionary<string, string> OriginalValue { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖后的显示值(多语言)。
|
||||
/// </summary>
|
||||
public Dictionary<string, string> OverrideValue { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖类型。
|
||||
/// </summary>
|
||||
public OverrideType OverrideType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖类型名称。
|
||||
/// </summary>
|
||||
public string OverrideTypeName => OverrideType switch
|
||||
{
|
||||
OverrideType.TenantCustomization => "租户定制",
|
||||
OverrideType.PlatformEnforcement => "平台强制",
|
||||
_ => "未知"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖原因/备注。
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建人 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long? CreatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新人 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long? UpdatedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建/更新标签覆盖请求。
|
||||
/// </summary>
|
||||
public sealed class UpsertLabelOverrideRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 被覆盖的字典项 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long DictionaryItemId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖后的显示值(多语言)。
|
||||
/// </summary>
|
||||
public Dictionary<string, string> OverrideValue { get; init; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖原因/备注(平台强制覆盖时建议填写)。
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量覆盖请求。
|
||||
/// </summary>
|
||||
public sealed class BatchLabelOverrideRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 覆盖项列表。
|
||||
/// </summary>
|
||||
public List<UpsertLabelOverrideRequest> Items { get; init; } = new();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<DictionaryAppService> logger) : IDictionaryAppService
|
||||
{
|
||||
/// <summary>
|
||||
@@ -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, "仅平台管理员可操作系统字典");
|
||||
}
|
||||
|
||||
@@ -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<DictionaryCommandService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
@@ -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, "仅平台管理员可操作系统字典");
|
||||
}
|
||||
|
||||
@@ -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<DictionaryImportExportService> 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, "仅平台管理员可操作系统字典");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 字典标签覆盖服务。
|
||||
/// </summary>
|
||||
public sealed class DictionaryLabelOverrideService(
|
||||
IDictionaryLabelOverrideRepository overrideRepository,
|
||||
IDictionaryItemRepository itemRepository)
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取租户的所有标签覆盖。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="overrideType">可选的覆盖类型过滤。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
public async Task<IReadOnlyList<LabelOverrideDto>> GetOverridesAsync(
|
||||
long tenantId,
|
||||
OverrideType? overrideType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var overrides = await overrideRepository.ListByTenantAsync(tenantId, overrideType, cancellationToken);
|
||||
return overrides.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定字典项的覆盖配置。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="dictionaryItemId">字典项 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
public async Task<LabelOverrideDto?> GetOverrideByItemIdAsync(
|
||||
long tenantId,
|
||||
long dictionaryItemId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await overrideRepository.GetByItemIdAsync(tenantId, dictionaryItemId, cancellationToken);
|
||||
return entity == null ? null : MapToDto(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建或更新租户对系统字典的标签覆盖(租户定制)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="request">覆盖请求。</param>
|
||||
/// <param name="operatorId">操作人 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
public async Task<LabelOverrideDto> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建或更新平台对租户字典的强制覆盖(平台强制)。
|
||||
/// </summary>
|
||||
/// <param name="targetTenantId">目标租户 ID。</param>
|
||||
/// <param name="request">覆盖请求。</param>
|
||||
/// <param name="operatorId">操作人 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
public async Task<LabelOverrideDto> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除覆盖配置。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="dictionaryItemId">字典项 ID。</param>
|
||||
/// <param name="operatorId">操作人 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
public async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取字典项的覆盖值映射。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="dictionaryItemIds">字典项 ID 列表。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>字典项 ID 到覆盖值的映射。</returns>
|
||||
public async Task<Dictionary<long, Dictionary<string, string>>> GetOverrideValuesMapAsync(
|
||||
long tenantId,
|
||||
IEnumerable<long> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 收集
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 字典标签覆盖配置。
|
||||
/// </summary>
|
||||
public sealed class DictionaryLabelOverride : EntityBase, IMultiTenantEntity, IAuditableEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属租户 ID(覆盖目标租户)。
|
||||
/// </summary>
|
||||
public long TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 被覆盖的字典项 ID。
|
||||
/// </summary>
|
||||
public long DictionaryItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原始显示值(JSON 格式,多语言)。
|
||||
/// </summary>
|
||||
public string OriginalValue { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖后的显示值(JSON 格式,多语言)。
|
||||
/// </summary>
|
||||
public string OverrideValue { get; set; } = "{}";
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖类型。
|
||||
/// </summary>
|
||||
public OverrideType OverrideType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 覆盖原因/备注。
|
||||
/// </summary>
|
||||
public string? Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近更新时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 删除时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建人用户标识。
|
||||
/// </summary>
|
||||
public long? CreatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后更新人用户标识。
|
||||
/// </summary>
|
||||
public long? UpdatedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 删除人用户标识。
|
||||
/// </summary>
|
||||
public long? DeletedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航属性:被覆盖的字典项。
|
||||
/// </summary>
|
||||
public DictionaryItem? DictionaryItem { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 字典覆盖类型。
|
||||
/// </summary>
|
||||
public enum OverrideType
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户定制:租户覆盖系统字典的显示值。
|
||||
/// </summary>
|
||||
TenantCustomization = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 平台强制:平台管理员强制修正租户字典的显示值。
|
||||
/// </summary>
|
||||
PlatformEnforcement = 2
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using TakeoutSaaS.Domain.Dictionary.Entities;
|
||||
using TakeoutSaaS.Domain.Dictionary.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Dictionary.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 字典标签覆盖仓储契约。
|
||||
/// </summary>
|
||||
public interface IDictionaryLabelOverrideRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据 ID 获取覆盖配置。
|
||||
/// </summary>
|
||||
Task<DictionaryLabelOverride?> GetByIdAsync(long id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定字典项的覆盖配置。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="dictionaryItemId">字典项 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task<DictionaryLabelOverride?> GetByItemIdAsync(long tenantId, long dictionaryItemId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户的所有覆盖配置。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="overrideType">可选的覆盖类型过滤。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task<IReadOnlyList<DictionaryLabelOverride>> ListByTenantAsync(
|
||||
long tenantId,
|
||||
OverrideType? overrideType = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取多个字典项的覆盖配置。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="dictionaryItemIds">字典项 ID 列表。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
Task<IReadOnlyList<DictionaryLabelOverride>> GetByItemIdsAsync(
|
||||
long tenantId,
|
||||
IEnumerable<long> dictionaryItemIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增覆盖配置。
|
||||
/// </summary>
|
||||
Task AddAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 更新覆盖配置。
|
||||
/// </summary>
|
||||
Task UpdateAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除覆盖配置。
|
||||
/// </summary>
|
||||
Task DeleteAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 当前请求租户 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ public static class DictionaryServiceCollectionExtensions
|
||||
services.AddScoped<IDictionaryGroupRepository, DictionaryGroupRepository>();
|
||||
services.AddScoped<IDictionaryItemRepository, DictionaryItemRepository>();
|
||||
services.AddScoped<ITenantDictionaryOverrideRepository, TenantDictionaryOverrideRepository>();
|
||||
services.AddScoped<IDictionaryLabelOverrideRepository, DictionaryLabelOverrideRepository>();
|
||||
services.AddScoped<IDictionaryImportLogRepository, DictionaryImportLogRepository>();
|
||||
services.AddScoped<ICacheInvalidationLogRepository, CacheInvalidationLogRepository>();
|
||||
services.AddScoped<ISystemParameterRepository, EfSystemParameterRepository>();
|
||||
|
||||
@@ -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<DictionaryDbContext> 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)
|
||||
{
|
||||
/// <summary>
|
||||
/// 字典分组集合。
|
||||
@@ -45,6 +47,11 @@ public sealed class DictionaryDbContext(
|
||||
/// </summary>
|
||||
public DbSet<CacheInvalidationLog> CacheInvalidationLogs => Set<CacheInvalidationLog>();
|
||||
|
||||
/// <summary>
|
||||
/// 字典标签覆盖集合。
|
||||
/// </summary>
|
||||
public DbSet<DictionaryLabelOverride> DictionaryLabelOverrides => Set<DictionaryLabelOverride>();
|
||||
|
||||
/// <summary>
|
||||
/// 系统参数集合。
|
||||
/// </summary>
|
||||
@@ -62,6 +69,7 @@ public sealed class DictionaryDbContext(
|
||||
ConfigureGroup(modelBuilder.Entity<DictionaryGroup>(), isSqlite);
|
||||
ConfigureItem(modelBuilder.Entity<DictionaryItem>(), isSqlite);
|
||||
ConfigureOverride(modelBuilder.Entity<TenantDictionaryOverride>());
|
||||
ConfigureLabelOverride(modelBuilder.Entity<DictionaryLabelOverride>());
|
||||
ConfigureImportLog(modelBuilder.Entity<DictionaryImportLog>());
|
||||
ConfigureCacheInvalidationLog(modelBuilder.Entity<CacheInvalidationLog>());
|
||||
ConfigureSystemParameter(modelBuilder.Entity<SystemParameter>());
|
||||
@@ -228,4 +236,34 @@ public sealed class DictionaryDbContext(
|
||||
builder.HasIndex(x => x.TenantId);
|
||||
builder.HasIndex(x => new { x.TenantId, x.Key }).IsUnique();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置字典标签覆盖。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureLabelOverride(EntityTypeBuilder<DictionaryLabelOverride> 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<int>().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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>(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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 字典标签覆盖仓储实现。
|
||||
/// </summary>
|
||||
public sealed class DictionaryLabelOverrideRepository(DictionaryDbContext context) : IDictionaryLabelOverrideRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据 ID 获取覆盖配置。
|
||||
/// </summary>
|
||||
public Task<DictionaryLabelOverride?> GetByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DictionaryLabelOverrides
|
||||
.IgnoreQueryFilters()
|
||||
.Include(x => x.DictionaryItem)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定字典项的覆盖配置。
|
||||
/// </summary>
|
||||
public Task<DictionaryLabelOverride?> GetByItemIdAsync(long tenantId, long dictionaryItemId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return context.DictionaryLabelOverrides
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.TenantId == tenantId &&
|
||||
x.DictionaryItemId == dictionaryItemId &&
|
||||
x.DeletedAt == null,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取租户的所有覆盖配置。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryLabelOverride>> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取多个字典项的覆盖配置。
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DictionaryLabelOverride>> GetByItemIdsAsync(
|
||||
long tenantId,
|
||||
IEnumerable<long> dictionaryItemIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ids = dictionaryItemIds.ToArray();
|
||||
if (ids.Length == 0) return Array.Empty<DictionaryLabelOverride>();
|
||||
|
||||
return await context.DictionaryLabelOverrides
|
||||
.AsNoTracking()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(x =>
|
||||
x.TenantId == tenantId &&
|
||||
ids.Contains(x.DictionaryItemId) &&
|
||||
x.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增覆盖配置。
|
||||
/// </summary>
|
||||
public Task AddAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryLabelOverrides.Add(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新覆盖配置。
|
||||
/// </summary>
|
||||
public Task UpdateAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
context.DictionaryLabelOverrides.Update(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除覆盖配置。
|
||||
/// </summary>
|
||||
public Task DeleteAsync(DictionaryLabelOverride entity, CancellationToken cancellationToken = default)
|
||||
{
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
context.DictionaryLabelOverrides.Update(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化更改。
|
||||
/// </summary>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -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<IdentityDbContext> 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)
|
||||
{
|
||||
/// <summary>
|
||||
/// 管理后台用户集合。
|
||||
|
||||
@@ -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<TakeoutLogsDbContext> 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)
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户审计日志集合。
|
||||
|
||||
@@ -20,16 +20,15 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
name: "IX_dictionary_groups_TenantId_Code",
|
||||
table: "dictionary_groups");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
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<string>(
|
||||
name: "Key",
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("AffectedCacheKeyCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("影响的缓存键数量。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("DictionaryCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("字典编码。");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("操作类型。");
|
||||
|
||||
b.Property<long>("OperatorId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("操作人用户标识。");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("字典作用域。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("发生时间(UTC)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<bool>("AllowOverride")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasComment("是否允许租户覆盖。");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("描述信息。");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasComment("是否启用。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("分组名称。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("分组作用域:系统/业务。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("ConflictMode")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("冲突处理模式。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("DictionaryGroupCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("字典分组编码。");
|
||||
|
||||
b.Property<TimeSpan>("Duration")
|
||||
.HasColumnType("interval")
|
||||
.HasComment("处理耗时。");
|
||||
|
||||
b.Property<string>("ErrorDetails")
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("错误明细(JSON)。");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("导入文件名。");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("文件大小(字节)。");
|
||||
|
||||
b.Property<string>("Format")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasComment("文件格式(CSV/JSON)。");
|
||||
|
||||
b.Property<long>("OperatorId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("操作人用户标识。");
|
||||
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("处理时间(UTC)。");
|
||||
|
||||
b.Property<int>("SkipCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("跳过数量。");
|
||||
|
||||
b.Property<int>("SuccessCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("成功导入数量。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("描述信息。");
|
||||
|
||||
b.Property<long>("GroupId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("关联分组 ID。");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否默认项。");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasComment("是否启用。");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("字典项键。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(100)
|
||||
.HasComment("排序值,越小越靠前。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<string>("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<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<long>("SystemDictionaryGroupId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("系统字典分组 ID。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识。");
|
||||
|
||||
b.Property<string>("CustomSortOrder")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("自定义排序映射(JSON)。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("删除时间(UTC)。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识。");
|
||||
|
||||
b.PrimitiveCollection<long[]>("HiddenSystemItemIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("bigint[]")
|
||||
.HasComment("隐藏的系统字典项 ID 列表。");
|
||||
|
||||
b.Property<bool>("OverrideEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasComment("是否启用覆盖。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近更新时间(UTC)。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("描述信息。");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasComment("是否启用。");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("参数键,租户内唯一。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(100)
|
||||
.HasComment("排序值,越小越靠前。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using TakeoutSaaS.Infrastructure.Dictionary.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
{
|
||||
/// <summary>
|
||||
/// 为字典表添加 RowVersion 自动生成触发器。
|
||||
/// </summary>
|
||||
[DbContext(typeof(DictionaryDbContext))]
|
||||
[Migration("20251230170000_AddDictionaryRowVersionTriggers")]
|
||||
public partial class AddDictionaryRowVersionTriggers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("AffectedCacheKeyCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("影响的缓存键数量。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("DictionaryCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("字典编码。");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("操作类型。");
|
||||
|
||||
b.Property<long>("OperatorId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("操作人用户标识。");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("字典作用域。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("发生时间(UTC)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<bool>("AllowOverride")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasComment("是否允许租户覆盖。");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("描述信息。");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasComment("是否启用。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("分组名称。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("分组作用域:系统/业务。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("ConflictMode")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("冲突处理模式。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("DictionaryGroupCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("字典分组编码。");
|
||||
|
||||
b.Property<TimeSpan>("Duration")
|
||||
.HasColumnType("interval")
|
||||
.HasComment("处理耗时。");
|
||||
|
||||
b.Property<string>("ErrorDetails")
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("错误明细(JSON)。");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("导入文件名。");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("文件大小(字节)。");
|
||||
|
||||
b.Property<string>("Format")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasComment("文件格式(CSV/JSON)。");
|
||||
|
||||
b.Property<long>("OperatorId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("操作人用户标识。");
|
||||
|
||||
b.Property<DateTime>("ProcessedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("处理时间(UTC)。");
|
||||
|
||||
b.Property<int>("SkipCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("跳过数量。");
|
||||
|
||||
b.Property<int>("SuccessCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("成功导入数量。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("描述信息。");
|
||||
|
||||
b.Property<long>("GroupId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("关联分组 ID。");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否默认项。");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasComment("是否启用。");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("字典项键。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(100)
|
||||
.HasComment("排序值,越小越靠前。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<string>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("删除时间(UTC)。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识。");
|
||||
|
||||
b.Property<long>("DictionaryItemId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("被覆盖的字典项 ID。");
|
||||
|
||||
b.Property<string>("OriginalValue")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("原始显示值(JSON 格式,多语言)。");
|
||||
|
||||
b.Property<int>("OverrideType")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("覆盖类型。");
|
||||
|
||||
b.Property<string>("OverrideValue")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("覆盖后的显示值(JSON 格式,多语言)。");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("覆盖原因/备注。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID(覆盖目标租户)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近更新时间(UTC)。");
|
||||
|
||||
b.Property<long?>("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<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<long>("SystemDictionaryGroupId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("系统字典分组 ID。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识。");
|
||||
|
||||
b.Property<string>("CustomSortOrder")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("自定义排序映射(JSON)。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("删除时间(UTC)。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识。");
|
||||
|
||||
b.PrimitiveCollection<long[]>("HiddenSystemItemIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("bigint[]")
|
||||
.HasComment("隐藏的系统字典项 ID 列表。");
|
||||
|
||||
b.Property<bool>("OverrideEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasComment("是否启用覆盖。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近更新时间(UTC)。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("描述信息。");
|
||||
|
||||
b.Property<bool>("IsEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true)
|
||||
.HasComment("是否启用。");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("参数键,租户内唯一。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(100)
|
||||
.HasComment("排序值,越小越靠前。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDictionaryLabelOverrides : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "dictionary_label_overrides",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false, comment: "实体唯一标识。")
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
TenantId = table.Column<long>(type: "bigint", nullable: false, comment: "所属租户 ID(覆盖目标租户)。"),
|
||||
DictionaryItemId = table.Column<long>(type: "bigint", nullable: false, comment: "被覆盖的字典项 ID。"),
|
||||
OriginalValue = table.Column<string>(type: "jsonb", nullable: false, comment: "原始显示值(JSON 格式,多语言)。"),
|
||||
OverrideValue = table.Column<string>(type: "jsonb", nullable: false, comment: "覆盖后的显示值(JSON 格式,多语言)。"),
|
||||
OverrideType = table.Column<int>(type: "integer", nullable: false, comment: "覆盖类型。"),
|
||||
Reason = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true, comment: "覆盖原因/备注。"),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, comment: "创建时间(UTC)。"),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "最近更新时间(UTC)。"),
|
||||
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true, comment: "删除时间(UTC)。"),
|
||||
CreatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "创建人用户标识。"),
|
||||
UpdatedBy = table.Column<long>(type: "bigint", nullable: true, comment: "最后更新人用户标识。"),
|
||||
DeletedBy = table.Column<long>(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" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "dictionary_label_overrides");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,79 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.CacheInvalidationLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("AffectedCacheKeyCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("影响的缓存键数量。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("DictionaryCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("字典编码。");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("操作类型。");
|
||||
|
||||
b.Property<long>("OperatorId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("操作人用户标识。");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("字典作用域。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("发生时间(UTC)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
@@ -211,79 +284,6 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.CacheInvalidationLog", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<int>("AffectedCacheKeyCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("影响的缓存键数量。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<string>("DictionaryCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("字典编码。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("操作类型。");
|
||||
|
||||
b.Property<long>("OperatorId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("操作人用户标识。");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("字典作用域。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime>("Timestamp")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("发生时间(UTC)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
@@ -380,6 +380,84 @@ namespace TakeoutSaaS.Infrastructure.Migrations.DictionaryDb
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TakeoutSaaS.Domain.Dictionary.Entities.DictionaryLabelOverride", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("删除时间(UTC)。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识。");
|
||||
|
||||
b.Property<long>("DictionaryItemId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("被覆盖的字典项 ID。");
|
||||
|
||||
b.Property<string>("OriginalValue")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("原始显示值(JSON 格式,多语言)。");
|
||||
|
||||
b.Property<int>("OverrideType")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("覆盖类型。");
|
||||
|
||||
b.Property<string>("OverrideValue")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasComment("覆盖后的显示值(JSON 格式,多语言)。");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("覆盖原因/备注。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID(覆盖目标租户)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近更新时间(UTC)。");
|
||||
|
||||
b.Property<long?>("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<long>("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");
|
||||
|
||||
Reference in New Issue
Block a user