feat(saas): implement marketplace and tenant onboarding
This commit is contained in:
10
README.md
10
README.md
@@ -83,12 +83,15 @@ PostgreSQL guard 负责 EF 无法表达的跨表租户不变量:
|
||||
- `TenantQuestionReference` 只能引用平台公共题或当前租户私题;
|
||||
- `TaxonomyNode` 父节点只能属于平台主体或当前租户;
|
||||
- 需要读取 `tenants.mode` 或比较 owner 关系的条件约束。
|
||||
- 已发布套餐版本及其模块、额度清单不可修改;订阅套餐类型与订单快照必须一致。
|
||||
- 套餐控制 Feature,角色控制 Permission,菜单仅按有效权限生成导航;内容后台按题库、词汇、手册、视频、分数线和站点内容分模块授权。
|
||||
- 员工、学生、私有题、存储、导入、导出和短信额度在业务写入时原子消费,Worker 定期按真实数据校准当前量。
|
||||
|
||||
维护规则:
|
||||
|
||||
1. SQL 只放在 `Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs`。
|
||||
2. Migration 只调用 `migrationBuilder.EnsureTenantIsolationGuards()` / `DropTenantIsolationGuards()`。
|
||||
3. 重建 `InitialSchema` 后,`Up()` 末尾必须调用 `EnsureTenantIsolationGuards()`,`Down()` 开头必须调用 `DropTenantIsolationGuards()`。
|
||||
1. 租户 SQL 放在 `PostgreSqlTenantConstraintSql.cs`,SaaS 商品 SQL 放在 `PostgreSqlSaasCatalogConstraintSql.cs`。
|
||||
2. Migration 只调用集中 helper,不复制 trigger SQL。
|
||||
3. 重建 `InitialSchema` 后,`Up()` 末尾必须调用 `EnsureTenantIsolationGuards()` 和 `EnsureSaasCatalogGuards()`;`Down()` 先调用对应 Drop helper。
|
||||
4. 新增 guard 前先判断能否用 EF FK / unique / check 表达;表达不了才加 PostgreSQL guard。
|
||||
5. 每个 guard 必须有 migration script 断言和真实 PostgreSQL 越权测试。
|
||||
|
||||
@@ -110,6 +113,7 @@ PostgreSQL guard 负责 EF 无法表达的跨表租户不变量:
|
||||
- [迁移路线与剩余范围](docs/migration-roadmap.md)
|
||||
- [API 契约基线说明](docs/migration/contracts/README.md)
|
||||
- [AI 底座设计](docs/migration/phase-8-ai-foundation.md)
|
||||
- [第九阶段:SaaS 模块商城与租户交付闭环](docs/migration/phase-9-saas-marketplace-and-onboarding.md)
|
||||
|
||||
## 常用命令
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using Serilog;
|
||||
using Tiku.Api.Logging;
|
||||
using Tiku.Api.Middleware;
|
||||
using Tiku.Api.Options;
|
||||
using Tiku.Api.Security;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
|
||||
@@ -35,6 +36,7 @@ public static class ApplicationBuilderExtensions
|
||||
app.UseRateLimiter();
|
||||
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<SaasFeatureAccessMiddleware>();
|
||||
app.MapControllers();
|
||||
|
||||
return app;
|
||||
|
||||
@@ -6,15 +6,27 @@ using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 资产访问签名查询参数。
|
||||
/// </summary>
|
||||
public sealed class AssetAccessQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期秒数。
|
||||
/// </summary>
|
||||
[Range(1, 7200)]
|
||||
public int? ExpiresInSeconds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 资产访问签名响应。
|
||||
/// </summary>
|
||||
public sealed record AssetAccessResponseDto(
|
||||
ContentAssetAccessSummaryDto Item,
|
||||
AssetAccessPrincipalDto Access,
|
||||
@@ -34,6 +46,9 @@ public sealed record AssetAccessResponseDto(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 内容资产访问摘要。
|
||||
/// </summary>
|
||||
public sealed record ContentAssetAccessSummaryDto(
|
||||
Guid Id,
|
||||
ContentAssetType AssetType,
|
||||
@@ -60,11 +75,17 @@ public sealed record ContentAssetAccessSummaryDto(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 资产访问Principal请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record AssetAccessPrincipalDto(
|
||||
Guid? UserId,
|
||||
bool IsMember,
|
||||
bool HasSvip);
|
||||
|
||||
/// <summary>
|
||||
/// SignedStorageUrl请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record SignedStorageUrlDto(
|
||||
string Provider,
|
||||
string? Bucket,
|
||||
|
||||
@@ -3,39 +3,84 @@ using Tiku.Application.Assets;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 资产查询参数。
|
||||
/// </summary>
|
||||
public sealed class AssetQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
public Guid? AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? AssetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产键。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? AssetKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含锁定资源。
|
||||
/// </summary>
|
||||
public bool IncludeLocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含停用数据。
|
||||
/// </summary>
|
||||
public bool IncludeInactive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
|
||||
@@ -5,64 +5,130 @@ using Tiku.Domain.Common;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 创建资产上传签名请求。
|
||||
/// </summary>
|
||||
public sealed class AssetUploadSignDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
public Guid? AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产键。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? AssetKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(500)]
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// MIME 类型。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小,单位为字节。
|
||||
/// </summary>
|
||||
[Range(0, long.MaxValue)]
|
||||
public long? FileSizeBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 校验值。
|
||||
/// </summary>
|
||||
[RegularExpression("^[A-Fa-f0-9]{64}$")]
|
||||
public string? ChecksumSha256 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? AssetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可见性。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Visibility { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否公开。
|
||||
/// </summary>
|
||||
public bool? IsPublic { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Provider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 存储桶。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Bucket { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对象存储键。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? ObjectKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期秒数。
|
||||
/// </summary>
|
||||
[Range(1, 3600)]
|
||||
public int? ExpiresInSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public AssetUploadSignCommand ToCommand()
|
||||
@@ -92,17 +158,32 @@ public sealed class AssetUploadSignDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确认资产上传请求。
|
||||
/// </summary>
|
||||
public sealed class AssetUploadConfirmDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME 类型。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? MimeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文件大小,单位为字节。
|
||||
/// </summary>
|
||||
[Range(0, long.MaxValue)]
|
||||
public long? FileSizeBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 校验值。
|
||||
/// </summary>
|
||||
[RegularExpression("^[A-Fa-f0-9]{64}$")]
|
||||
public string? ChecksumSha256 { get; set; }
|
||||
|
||||
@@ -112,34 +193,118 @@ public sealed class AssetUploadConfirmDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新内容资产请求。
|
||||
/// </summary>
|
||||
public sealed class UpsertAssetDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
public Guid? AssetId { get; set; }
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 资产键。
|
||||
/// </summary>
|
||||
public string? AssetKey { get; set; }
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string? Title { get; set; }
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
public string? Category { get; set; }
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
/// <summary>
|
||||
/// 文件名。
|
||||
/// </summary>
|
||||
public string? FileName { get; set; }
|
||||
/// <summary>
|
||||
/// CDN 地址。
|
||||
/// </summary>
|
||||
public string? CdnUrl { get; set; }
|
||||
/// <summary>
|
||||
/// 是否公开。
|
||||
/// </summary>
|
||||
public bool? IsPublic { get; set; }
|
||||
/// <summary>
|
||||
/// 资产类型。
|
||||
/// </summary>
|
||||
public string? AssetType { get; set; }
|
||||
/// <summary>
|
||||
/// 可见性。
|
||||
/// </summary>
|
||||
public string? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
public string? Provider { get; set; }
|
||||
/// <summary>
|
||||
/// 存储桶。
|
||||
/// </summary>
|
||||
public string? Bucket { get; set; }
|
||||
/// <summary>
|
||||
/// 对象存储键。
|
||||
/// </summary>
|
||||
public string? ObjectKey { get; set; }
|
||||
/// <summary>
|
||||
/// MIME 类型。
|
||||
/// </summary>
|
||||
public string? MimeType { get; set; }
|
||||
/// <summary>
|
||||
/// 文件大小,单位为字节。
|
||||
/// </summary>
|
||||
public long? FileSizeBytes { get; set; }
|
||||
/// <summary>
|
||||
/// SHA-256 校验值。
|
||||
/// </summary>
|
||||
public string? ChecksumSha256 { get; set; }
|
||||
/// <summary>
|
||||
/// 预览地址。
|
||||
/// </summary>
|
||||
public string? PreviewUrl { get; set; }
|
||||
/// <summary>
|
||||
/// 预览对象存储键。
|
||||
/// </summary>
|
||||
public string? PreviewObjectKey { get; set; }
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
/// <summary>
|
||||
/// 访问规则。
|
||||
/// </summary>
|
||||
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertAssetCommand ToCommand()
|
||||
@@ -175,11 +340,20 @@ public sealed class UpsertAssetDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建资产访问签名请求。
|
||||
/// </summary>
|
||||
public sealed class AssetAccessSignDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid AssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期秒数。
|
||||
/// </summary>
|
||||
[Range(60, 3600)]
|
||||
public int? ExpiresInSeconds { get; set; }
|
||||
|
||||
@@ -189,31 +363,64 @@ public sealed class AssetAccessSignDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 资产管理查询参数。
|
||||
/// </summary>
|
||||
public sealed class AssetManagementQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资产类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? AssetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? UploadStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 安全扫描状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? SecurityScanStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
@@ -233,11 +440,23 @@ public sealed class AssetManagementQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 资产事件查询参数。
|
||||
/// </summary>
|
||||
public sealed class AssetEventQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
public Guid? AssetId { get; set; }
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
@@ -247,17 +466,32 @@ public sealed class AssetEventQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入任务查询参数。
|
||||
/// </summary>
|
||||
public sealed class ImportJobQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导入Type。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? ImportType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源格式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? SourceFormat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
|
||||
@@ -6,32 +6,38 @@ using Tiku.Domain.Tenancy;
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号密码登录请求。
|
||||
/// PasswordLogin请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class PasswordLoginDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 认证域。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public AuthRealm? Realm { get; set; }
|
||||
/// <summary>
|
||||
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账号标识。tenant 可使用手机号,platform 可使用邮箱或用户名。
|
||||
/// 账号标识。
|
||||
/// </summary>
|
||||
[StringLength(320)]
|
||||
[Description("账号标识。tenant 可使用手机号,platform 可使用邮箱或用户名。")]
|
||||
public string? Identifier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兼容手机号字段;新客户端应使用 identifier。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
[Description("兼容手机号字段;新客户端应使用 identifier。")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户密码。
|
||||
/// 密码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
@@ -40,21 +46,24 @@ public sealed class PasswordLoginDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 短信验证码登录请求。
|
||||
/// SmsLogin请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class SmsLoginDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 认证域。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public AuthRealm? Realm { get; set; }
|
||||
/// <summary>
|
||||
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 中国大陆手机号。
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
@@ -62,7 +71,7 @@ public sealed class SmsLoginDto
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 收到的短信验证码。
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(12, MinimumLength = 4)]
|
||||
@@ -70,38 +79,56 @@ public sealed class SmsLoginDto
|
||||
public string Code { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发送短信验证码请求。
|
||||
/// </summary>
|
||||
public sealed class SendSmsCodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 认证域。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public AuthRealm? Realm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32)]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 设备 ID。
|
||||
/// </summary>
|
||||
[StringLength(256)]
|
||||
public string? DeviceId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OAuth code 登录请求。
|
||||
/// O认证编码请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class OAuthCodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 认证域。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public AuthRealm? Realm { get; set; }
|
||||
/// <summary>
|
||||
/// 平台控制域名登录时使用的租户代码;自定义域名登录可省略。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
[Description("平台控制域名登录时使用的租户代码;自定义域名登录可省略。")]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// OAuth 平台返回的一次性授权 code。
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(512)]
|
||||
@@ -109,13 +136,13 @@ public sealed class OAuthCodeDto
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 客户端可提供的公开用户资料,不允许包含 token/secret。当前后端先保留模板字段,后续按业务需要逐步使用。
|
||||
/// 公开用户资料。
|
||||
/// </summary>
|
||||
[Description("客户端可提供的公开用户资料,不允许包含 token/secret。当前后端先保留模板字段,后续按业务需要逐步使用。")]
|
||||
public Dictionary<string, object?>? Profile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信用户资料语言,例如 zh_CN。当前微信小程序登录不会使用该字段。
|
||||
/// 语言。
|
||||
/// </summary>
|
||||
[StringLength(20)]
|
||||
[Description("微信用户资料语言,例如 zh_CN。当前微信小程序登录不会使用该字段。")]
|
||||
@@ -123,12 +150,12 @@ public sealed class OAuthCodeDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// refresh token 请求。
|
||||
/// 刷新会话请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class RefreshSessionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// refresh token。服务端只存储哈希,明文只返回给客户端一次。
|
||||
/// 刷新令牌。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(2048)]
|
||||
@@ -137,7 +164,7 @@ public sealed class RefreshSessionDto
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 登录成功后的用户、租户成员和令牌信息。
|
||||
/// Authenticated用户请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class AuthenticatedUserDto
|
||||
{
|
||||
@@ -157,19 +184,22 @@ public sealed class AuthenticatedUserDto
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户显示名称。
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 认证域。
|
||||
/// </summary>
|
||||
public AuthRealm Realm { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前登录租户成员摘要。
|
||||
/// 租户成员摘要。
|
||||
/// </summary>
|
||||
public TenantMembershipSummary? Tenant { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// access token 和 refresh token。
|
||||
/// 访问令牌和刷新令牌。
|
||||
/// </summary>
|
||||
public AuthTokenPair Tokens { get; init; } = default!;
|
||||
|
||||
@@ -188,11 +218,26 @@ public sealed class AuthenticatedUserDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 登录认证结果。
|
||||
/// </summary>
|
||||
public sealed class AuthenticationResultDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public AuthenticationStatus Status { get; init; }
|
||||
/// <summary>
|
||||
/// 用户信息。
|
||||
/// </summary>
|
||||
public AuthenticatedUserDto? User { get; init; }
|
||||
/// <summary>
|
||||
/// 挑战令牌。
|
||||
/// </summary>
|
||||
public string? ChallengeToken { get; init; }
|
||||
/// <summary>
|
||||
/// 挑战令牌过期时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? ChallengeExpiresAt { get; init; }
|
||||
|
||||
public static AuthenticationResultDto FromApplication(AuthenticationResult result) => new()
|
||||
@@ -204,12 +249,21 @@ public sealed class AuthenticationResultDto
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 首次登录必改密码请求。
|
||||
/// </summary>
|
||||
public sealed class RequiredPasswordChangeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 挑战令牌。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(2048)]
|
||||
public string ChallengeToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 新密码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
|
||||
@@ -3,11 +3,26 @@ using Tiku.Application.Jobs;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 创建租户后台任务请求。
|
||||
/// </summary>
|
||||
public sealed class CreateBackgroundJobDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务类型。
|
||||
/// </summary>
|
||||
public string JobType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// 任务载荷。
|
||||
/// </summary>
|
||||
public JsonElement Payload { get; set; }
|
||||
/// <summary>
|
||||
/// 计划运行时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? RunAfter { get; set; }
|
||||
/// <summary>
|
||||
/// 最大重试次数。
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
public CreateBackgroundJobCommand ToCommand(Guid tenantId)
|
||||
|
||||
@@ -4,13 +4,34 @@ using Tiku.Domain.Operations;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 创建或更新后台角色请求。
|
||||
/// </summary>
|
||||
public sealed class UpsertBackofficeRoleDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public BackendRoleStatus Status { get; set; } = BackendRoleStatus.Active;
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
/// <summary>
|
||||
/// 数据范围配置。
|
||||
/// </summary>
|
||||
public JsonElement? DataScope { get; set; }
|
||||
|
||||
public UpsertBackofficeRoleCommand ToCommand()
|
||||
@@ -19,9 +40,18 @@ public sealed class UpsertBackofficeRoleDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换角色权限绑定请求。
|
||||
/// </summary>
|
||||
public sealed class ReplaceRoleBindingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 权限编码列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> PermissionCodes { get; set; } = [];
|
||||
/// <summary>
|
||||
/// 菜单编码列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> MenuCodes { get; set; } = [];
|
||||
|
||||
public ReplaceRoleBindingsCommand ToCommand(Guid roleId)
|
||||
@@ -30,8 +60,14 @@ public sealed class ReplaceRoleBindingsDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换用户角色请求。
|
||||
/// </summary>
|
||||
public sealed class ReplaceUserRolesDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 角色 ID 列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Guid> RoleIds { get; set; } = [];
|
||||
|
||||
public ReplaceUserRolesCommand ToCommand(Guid userId)
|
||||
|
||||
@@ -3,10 +3,13 @@ using Tiku.Application.Catalog;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 目录查询参数。
|
||||
/// </summary>
|
||||
public sealed class CatalogQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。未登录公开查询时使用;已登录时优先使用当前 token 的租户。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
@@ -17,7 +20,7 @@ public sealed class CatalogQueryDto
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 功能模块 ID。
|
||||
/// ModuleId。
|
||||
/// </summary>
|
||||
public Guid? ModuleId { get; set; }
|
||||
|
||||
@@ -44,24 +47,24 @@ public sealed class CatalogQueryDto
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 导航节点 ID。
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称关键词。
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型过滤,例如 cultural、professional、chapter、paper。
|
||||
/// 类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回条数上限。
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 2000)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
@@ -3,25 +3,49 @@ using Tiku.Application.Commerce;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 创建交易订单请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreateCommerceOrderDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 套餐 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid PlanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
[Range(1, 99)]
|
||||
public int Quantity { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? PayMethod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付渠道。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? PayProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? CouponCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券领取记录 ID。
|
||||
/// </summary>
|
||||
public Guid? CouponRedemptionId { get; set; }
|
||||
|
||||
public CreateCommerceOrderCommand ToCommand()
|
||||
@@ -37,35 +61,65 @@ public sealed class CreateCommerceOrderDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易订单查询参数。
|
||||
/// </summary>
|
||||
public sealed class CommerceOrderQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 100)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建交易支付请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreateCommercePaymentDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 方式。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string Method { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 微信或支付渠道 openid。
|
||||
/// </summary>
|
||||
[StringLength(255)]
|
||||
public string? OpenId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付完成返回地址。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付取消返回地址。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? QuitUrl { get; set; }
|
||||
|
||||
@@ -81,11 +135,20 @@ public sealed class CreateCommercePaymentDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 交易优惠券查询参数。
|
||||
/// </summary>
|
||||
public sealed class CommerceCouponQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 100)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
@@ -95,8 +158,14 @@ public sealed class CommerceCouponQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 领取交易优惠券请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ClaimCommerceCouponDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 优惠券码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string CouponCode { get; set; } = string.Empty;
|
||||
@@ -107,19 +176,37 @@ public sealed class ClaimCommerceCouponDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验交易优惠券请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CheckCommerceCouponDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 优惠券码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? CouponCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优惠券领取记录 ID。
|
||||
/// </summary>
|
||||
public Guid? CouponRedemptionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 套餐 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid PlanId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 数量。
|
||||
/// </summary>
|
||||
[Range(1, 99)]
|
||||
public int Quantity { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
public CheckCommerceCouponCommand ToCommand()
|
||||
|
||||
@@ -5,132 +5,294 @@ using Tiku.Application.Growth;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 保存佣金配置请求。
|
||||
/// </summary>
|
||||
public sealed class UpdateCommissionSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 默认佣金比例。
|
||||
/// </summary>
|
||||
[Range(0, 1)]
|
||||
public decimal? DefaultRate { get; set; }
|
||||
/// <summary>
|
||||
/// 最低结算金额,单位为分。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? MinSettlementCents { get; set; }
|
||||
/// <summary>
|
||||
/// 结算周期。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? SettlementCycle { get; set; }
|
||||
/// <summary>
|
||||
/// 配置内容。
|
||||
/// </summary>
|
||||
public JsonElement? Config { get; set; }
|
||||
public UpdateCommissionSettingsCommand ToCommand() => new(DefaultRate, MinSettlementCents, SettlementCycle, Config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 调整成员佣金比例请求。
|
||||
/// </summary>
|
||||
public sealed class UpdateMemberCommissionRateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid UserId { get; set; }
|
||||
/// <summary>
|
||||
/// 成员佣金比例。
|
||||
/// </summary>
|
||||
[Range(0, 1)]
|
||||
public decimal? CommissionRate { get; set; }
|
||||
/// <summary>
|
||||
/// 佣金扩展配置。
|
||||
/// </summary>
|
||||
public JsonElement? CommissionConfig { get; set; }
|
||||
public UpdateMemberCommissionRateCommand ToCommand() => new(UserId, CommissionRate, CommissionConfig);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 佣金统计周期查询参数。
|
||||
/// </summary>
|
||||
public class CommissionPeriodQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 开始日期,格式为 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||
public string? StartDate { get; set; }
|
||||
/// <summary>
|
||||
/// 结束日期,格式为 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||
public string? EndDate { get; set; }
|
||||
/// <summary>
|
||||
/// 推荐人用户 ID。
|
||||
/// </summary>
|
||||
public Guid? ReferrerUserId { get; set; }
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
public CommissionPeriodQuery ToQuery() => new(ParseDate(StartDate), ParseDate(EndDate), ReferrerUserId, Limit);
|
||||
protected static DateOnly? ParseDate(string? value) => string.IsNullOrWhiteSpace(value) ? null : DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 佣金结算单查询参数。
|
||||
/// </summary>
|
||||
public sealed class CommissionSettlementsQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
/// <summary>
|
||||
/// 推荐人用户 ID。
|
||||
/// </summary>
|
||||
public Guid? ReferrerUserId { get; set; }
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
public CommissionSettlementQuery ToQuery() => new(Status, ReferrerUserId, Limit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成佣金结算单请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class GenerateCommissionSettlementDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 开始日期,格式为 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||
public string StartDate { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// 结束日期,格式为 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||
public string EndDate { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// 推荐人用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid ReferrerUserId { get; set; }
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Remark { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
public GenerateCommissionSettlementCommand ToCommand() => new(Parse(StartDate), Parse(EndDate), ReferrerUserId, Status, Remark, Metadata);
|
||||
private static DateOnly Parse(string value) => DateOnly.ParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新佣金结算单状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdateCommissionSettlementStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 结算单 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid SettlementId { get; set; }
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
/// <summary>
|
||||
/// 审核备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? ReviewNote { get; set; }
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? PaymentMethod { get; set; }
|
||||
/// <summary>
|
||||
/// 收款账号。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? PaymentAccount { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
public UpdateCommissionSettlementStatusCommand ToCommand() => new(SettlementId, Status, ReviewNote, PaymentMethod, PaymentAccount, Metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 佣金结算单导出查询参数。
|
||||
/// </summary>
|
||||
public sealed class CommissionSettlementExportQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 结算单 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid SettlementId { get; set; }
|
||||
/// <summary>
|
||||
/// 导出格式。
|
||||
/// </summary>
|
||||
[StringLength(10)]
|
||||
public string? Format { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 佣金结算单凭证查询参数。
|
||||
/// </summary>
|
||||
public sealed class CommissionSettlementProofQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 结算单 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid SettlementId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建佣金结算凭证请求。
|
||||
/// </summary>
|
||||
public sealed class CreateCommissionProofDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 结算单 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid SettlementId { get; set; }
|
||||
/// <summary>
|
||||
/// 凭证类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? ProofType { get; set; }
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Title { get; set; }
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[StringLength(2000)]
|
||||
public string? Description { get; set; }
|
||||
/// <summary>
|
||||
/// 资产 ID。
|
||||
/// </summary>
|
||||
public Guid? AssetId { get; set; }
|
||||
/// <summary>
|
||||
/// 外部链接。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? ExternalUrl { get; set; }
|
||||
/// <summary>
|
||||
/// 金额,单位为分。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? AmountCents { get; set; }
|
||||
/// <summary>
|
||||
/// 支付方式。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? PaymentMethod { get; set; }
|
||||
/// <summary>
|
||||
/// 收款账号。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? PaymentAccount { get; set; }
|
||||
/// <summary>
|
||||
/// 支付时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? PaidAt { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
public CreateCommissionProofCommand ToCommand() => new(SettlementId, ProofType, Title, Description, AssetId, ExternalUrl, AmountCents, PaymentMethod, PaymentAccount, PaidAt, Metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新佣金结算凭证状态请求。
|
||||
/// </summary>
|
||||
public sealed class UpdateCommissionProofStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 凭证 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid ProofId { get; set; }
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
/// <summary>
|
||||
/// 审核备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? ReviewNote { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
public UpdateCommissionProofStatusCommand ToCommand() => new(ProofId, Status, ReviewNote, Metadata);
|
||||
}
|
||||
|
||||
@@ -7,37 +7,76 @@ using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 内容管理查询参数。
|
||||
/// </summary>
|
||||
public sealed class ContentManagementQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
public Guid? CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父节点 ID;传 root 表示根节点。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
[RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")]
|
||||
public string? ParentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入口类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? EntryType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? CollectionType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标记类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? MarkerType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含停用数据。
|
||||
/// </summary>
|
||||
public bool IncludeInactive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 1000)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
@@ -59,43 +98,88 @@ public sealed class ContentManagementQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新内容条目请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertContentEntryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? LegacyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入口键。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? EntryKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 入口类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? EntryType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图标。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 路由地址。
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? Route { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[StringLength(2000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 可见性。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Visibility { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问规则。
|
||||
/// </summary>
|
||||
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 布局配置。
|
||||
/// </summary>
|
||||
public JsonElement LayoutConfig { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; set; }
|
||||
|
||||
public UpsertContentEntryCommand ToCommand()
|
||||
@@ -118,45 +202,96 @@ public sealed class UpsertContentEntryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新内容节点请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertContentNodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ParentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? LegacyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点键。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? NodeKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 节点Type。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? NodeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标记类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? MarkerType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标记配置。
|
||||
/// </summary>
|
||||
public JsonElement MarkerConfig { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否可选择。
|
||||
/// </summary>
|
||||
public bool? IsSelectable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否叶子节点。
|
||||
/// </summary>
|
||||
public bool? IsLeaf { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问规则。
|
||||
/// </summary>
|
||||
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertContentNodeCommand ToCommand()
|
||||
@@ -181,48 +316,105 @@ public sealed class UpsertContentNodeDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新题目题集请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertQuestionCollectionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题库 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionBankId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? LegacyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 题集类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? CollectionType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? SourceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 筛选条件。
|
||||
/// </summary>
|
||||
public JsonElement Filters { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 总分。
|
||||
/// </summary>
|
||||
public decimal? TotalScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 时长,单位为分钟。
|
||||
/// </summary>
|
||||
public int? DurationMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问规则。
|
||||
/// </summary>
|
||||
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertQuestionCollectionCommand ToCommand()
|
||||
@@ -249,23 +441,47 @@ public sealed class UpsertQuestionCollectionDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 题集题目请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CollectionQuestionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public QuestionSource Source { get; set; } = QuestionSource.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// 分段键。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? SectionKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分数。
|
||||
/// </summary>
|
||||
public decimal? Score { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否必填。
|
||||
/// </summary>
|
||||
public bool? Required { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public CollectionQuestionCommand ToCommand()
|
||||
@@ -280,11 +496,20 @@ public sealed class CollectionQuestionDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换题集Items请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ReplaceCollectionItemsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<CollectionQuestionDto> Questions { get; set; } = [];
|
||||
|
||||
public ReplaceCollectionItemsCommand ToCommand()
|
||||
@@ -295,48 +520,105 @@ public sealed class ReplaceCollectionItemsDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新练习Blueprint请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertPracticeBlueprintDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
public Guid? CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? LegacyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 组卷方式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? AssemblyType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目数量上限。
|
||||
/// </summary>
|
||||
public int? QuestionLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 时长,单位为分钟。
|
||||
/// </summary>
|
||||
public int? DurationMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总分。
|
||||
/// </summary>
|
||||
public decimal? TotalScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 及格分。
|
||||
/// </summary>
|
||||
public decimal? PassScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分段配置。
|
||||
/// </summary>
|
||||
public JsonElement Sections { get; set; } = JsonDefaults.Array();
|
||||
|
||||
/// <summary>
|
||||
/// 规则配置。
|
||||
/// </summary>
|
||||
public JsonElement Rules { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 访问规则。
|
||||
/// </summary>
|
||||
public JsonElement AccessRules { get; set; } = JsonDefaults.Object();
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
|
||||
public UpsertPracticeBlueprintCommand ToCommand()
|
||||
@@ -363,12 +645,21 @@ public sealed class UpsertPracticeBlueprintDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 导入Template查询参数。
|
||||
/// </summary>
|
||||
public sealed class ImportTemplateQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 导入Type。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string ImportType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 导出格式。
|
||||
/// </summary>
|
||||
[StringLength(10)]
|
||||
public string? Format { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,42 +3,87 @@ using Tiku.Application.Content;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 内容Navigation查询参数。
|
||||
/// </summary>
|
||||
public sealed class ContentNavigationQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
public Guid? CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父节点 ID;传 root 表示根节点。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
[RegularExpression("^(root|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$")]
|
||||
public string? ParentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入口类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? EntryType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? CollectionType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标记类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? MarkerType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含隐藏数据。
|
||||
/// </summary>
|
||||
public bool IncludeHidden { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含停用数据。
|
||||
/// </summary>
|
||||
public bool IncludeInactive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 1000)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
|
||||
@@ -4,35 +4,71 @@ using Tiku.Application.Growth;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新CRM配置请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertCrmConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 访问地址。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 密钥引用。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 密钥内容。
|
||||
/// </summary>
|
||||
public string? Secret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 表单名称。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? FormName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 考试类型。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? ExamType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 超时时长,单位为秒。
|
||||
/// </summary>
|
||||
[Range(1, 120)]
|
||||
public int? TimeoutSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 延迟秒数。
|
||||
/// </summary>
|
||||
[Range(0, 86400)]
|
||||
public int? DelaySeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分配模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? AssignmentMode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分配池。
|
||||
/// </summary>
|
||||
public JsonElement? AssignmentPool { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分配配置。
|
||||
/// </summary>
|
||||
public JsonElement? AssignmentConfig { get; set; }
|
||||
|
||||
public UpsertCrmConfigCommand ToCommand()
|
||||
@@ -52,16 +88,31 @@ public sealed class UpsertCrmConfigDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRM队列查询参数。
|
||||
/// </summary>
|
||||
public sealed class CrmQueueQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 队列任务 ID。
|
||||
/// </summary>
|
||||
public Guid? QueueId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
@@ -71,10 +122,19 @@ public sealed class CrmQueueQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRM队列日志查询参数。
|
||||
/// </summary>
|
||||
public sealed class CrmQueueLogQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 队列任务 ID。
|
||||
/// </summary>
|
||||
public Guid? QueueId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
@@ -84,17 +144,32 @@ public sealed class CrmQueueLogQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CRM队列Action请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CrmQueueActionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 队列任务 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid QueueId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Action { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
public CrmQueueActionCommand ToCommand()
|
||||
|
||||
@@ -5,28 +5,73 @@ using Tiku.Domain.Common;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧内容查询参数。
|
||||
/// </summary>
|
||||
public sealed class DirectContentQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
/// <summary>
|
||||
/// 父节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ParentId { get; set; }
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
/// <summary>
|
||||
/// 章节 ID。
|
||||
/// </summary>
|
||||
public Guid? ChapterId { get; set; }
|
||||
/// <summary>
|
||||
/// 单元 ID。
|
||||
/// </summary>
|
||||
public Guid? UnitId { get; set; }
|
||||
/// <summary>
|
||||
/// 院校 ID。
|
||||
/// </summary>
|
||||
public Guid? SchoolId { get; set; }
|
||||
/// <summary>
|
||||
/// 专业 ID。
|
||||
/// </summary>
|
||||
public Guid? MajorId { get; set; }
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 年份。
|
||||
/// </summary>
|
||||
[Range(1900, 3000)]
|
||||
public int? Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 1000)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
@@ -50,37 +95,121 @@ public sealed class DirectContentQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧题目写入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectQuestionWriteDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionId { get; set; }
|
||||
/// <summary>
|
||||
/// 题库 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionBankId { get; set; }
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
/// <summary>
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
/// <summary>
|
||||
/// 主题集 ID。
|
||||
/// </summary>
|
||||
public Guid? PrimaryCollectionId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 类型。
|
||||
/// </summary>
|
||||
public string? Type { get; set; }
|
||||
/// <summary>
|
||||
/// 类型显示名。
|
||||
/// </summary>
|
||||
public string? TypeLabel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 难度。
|
||||
/// </summary>
|
||||
[Range(1, 5)]
|
||||
public int? Difficulty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标签列表。
|
||||
/// </summary>
|
||||
public JsonElement Tags { get; set; } = JsonDefaults.Array();
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string? Content { get; set; }
|
||||
/// <summary>
|
||||
/// 选项配置。
|
||||
/// </summary>
|
||||
public JsonElement Options { get; set; } = JsonDefaults.Array();
|
||||
/// <summary>
|
||||
/// 正确选项索引。
|
||||
/// </summary>
|
||||
public int? CorrectOptionIndex { get; set; }
|
||||
/// <summary>
|
||||
/// 正确选项索引列表。
|
||||
/// </summary>
|
||||
public JsonElement CorrectOptionIndices { get; set; } = JsonDefaults.Array();
|
||||
/// <summary>
|
||||
/// 文字答案。
|
||||
/// </summary>
|
||||
public string? AnswerText { get; set; }
|
||||
/// <summary>
|
||||
/// 解析。
|
||||
/// </summary>
|
||||
public string? Explanation { get; set; }
|
||||
/// <summary>
|
||||
/// 子题列表。
|
||||
/// </summary>
|
||||
public JsonElement SubQuestions { get; set; } = JsonDefaults.Array();
|
||||
/// <summary>
|
||||
/// 代码语言。
|
||||
/// </summary>
|
||||
public string? CodeLang { get; set; }
|
||||
/// <summary>
|
||||
/// 代码模板。
|
||||
/// </summary>
|
||||
public string? CodeTemplate { get; set; }
|
||||
/// <summary>
|
||||
/// 媒体地址。
|
||||
/// </summary>
|
||||
public string? MediaUrl { get; set; }
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public string? Status { get; set; }
|
||||
/// <summary>
|
||||
/// 考试标记配置。
|
||||
/// </summary>
|
||||
public JsonElement ExamMarkers { get; set; } = JsonDefaults.Object();
|
||||
/// <summary>
|
||||
/// 来源哈希。
|
||||
/// </summary>
|
||||
public string? SourceHash { get; set; }
|
||||
/// <summary>
|
||||
/// 是否创建题目版本。
|
||||
/// </summary>
|
||||
public bool? CreateVersion { get; set; }
|
||||
|
||||
public QuestionWriteCommand ToCommand(bool createVersionDefault)
|
||||
@@ -116,18 +245,54 @@ public sealed class DirectQuestionWriteDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧词汇单元写入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectVocabularyUnitDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
/// <summary>
|
||||
/// 单词数量。
|
||||
/// </summary>
|
||||
public int? WordCount { get; set; }
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public VocabularyUnitCommand ToCommand()
|
||||
@@ -136,22 +301,70 @@ public sealed class DirectVocabularyUnitDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧词汇单词写入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectVocabularyWordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 单元 ID。
|
||||
/// </summary>
|
||||
public Guid? UnitId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 单词。
|
||||
/// </summary>
|
||||
public required string Word { get; set; }
|
||||
/// <summary>
|
||||
/// 音标。
|
||||
/// </summary>
|
||||
public string? Phonetic { get; set; }
|
||||
/// <summary>
|
||||
/// 释义。
|
||||
/// </summary>
|
||||
public string? Meaning { get; set; }
|
||||
/// <summary>
|
||||
/// 例句。
|
||||
/// </summary>
|
||||
public string? Example { get; set; }
|
||||
/// <summary>
|
||||
/// 例句翻译。
|
||||
/// </summary>
|
||||
public string? ExampleTranslation { get; set; }
|
||||
/// <summary>
|
||||
/// 难度。
|
||||
/// </summary>
|
||||
public int? Difficulty { get; set; }
|
||||
/// <summary>
|
||||
/// 标签列表。
|
||||
/// </summary>
|
||||
public JsonElement Tags { get; set; } = JsonDefaults.Array();
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public VocabularyWordCommand ToCommand()
|
||||
@@ -175,22 +388,70 @@ public sealed class DirectVocabularyWordDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧知识手册科目写入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectHandbookSubjectDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
/// <summary>
|
||||
/// 院校 ID。
|
||||
/// </summary>
|
||||
public Guid? SchoolId { get; set; }
|
||||
/// <summary>
|
||||
/// 专业 ID。
|
||||
/// </summary>
|
||||
public Guid? MajorId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// 类型。
|
||||
/// </summary>
|
||||
public string? Type { get; set; }
|
||||
/// <summary>
|
||||
/// 图标。
|
||||
/// </summary>
|
||||
public string? Icon { get; set; }
|
||||
/// <summary>
|
||||
/// 颜色。
|
||||
/// </summary>
|
||||
public string? Color { get; set; }
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public HandbookSubjectCommand ToCommand()
|
||||
@@ -214,17 +475,50 @@ public sealed class DirectHandbookSubjectDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧知识手册章节写入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectHandbookChapterDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public HandbookChapterCommand ToCommand()
|
||||
@@ -233,19 +527,58 @@ public sealed class DirectHandbookChapterDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧知识手册条目写入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectHandbookEntryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 章节 ID。
|
||||
/// </summary>
|
||||
public Guid? ChapterId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public required string Title { get; set; }
|
||||
/// <summary>
|
||||
/// 摘要。
|
||||
/// </summary>
|
||||
public string? Summary { get; set; }
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string? Content { get; set; }
|
||||
/// <summary>
|
||||
/// 标签列表。
|
||||
/// </summary>
|
||||
public JsonElement Tags { get; set; } = JsonDefaults.Array();
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public HandbookEntryCommand ToCommand()
|
||||
@@ -254,13 +587,34 @@ public sealed class DirectHandbookEntryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧院校写入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectSchoolDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// 专业考试日期。
|
||||
/// </summary>
|
||||
public string? ProfessionalExamDate { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public SchoolCommand ToCommand()
|
||||
@@ -269,16 +623,46 @@ public sealed class DirectSchoolDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧专业写入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectMajorDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
/// <summary>
|
||||
/// 院校 ID。
|
||||
/// </summary>
|
||||
public Guid? SchoolId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
/// <summary>
|
||||
/// 备考建议。
|
||||
/// </summary>
|
||||
public string? StudyTips { get; set; }
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; set; }
|
||||
|
||||
public MajorCommand ToCommand()
|
||||
@@ -287,22 +671,70 @@ public sealed class DirectMajorDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧分数线字段写入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectScorelineFieldDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 字段键。
|
||||
/// </summary>
|
||||
public required string FieldKey { get; set; }
|
||||
/// <summary>
|
||||
/// 字段名称。
|
||||
/// </summary>
|
||||
public required string FieldName { get; set; }
|
||||
/// <summary>
|
||||
/// 字段类型。
|
||||
/// </summary>
|
||||
public string? FieldType { get; set; }
|
||||
/// <summary>
|
||||
/// 单位。
|
||||
/// </summary>
|
||||
public string? Unit { get; set; }
|
||||
/// <summary>
|
||||
/// 是否可筛选。
|
||||
/// </summary>
|
||||
public bool? IsFilter { get; set; }
|
||||
/// <summary>
|
||||
/// 是否必填。
|
||||
/// </summary>
|
||||
public bool? IsRequired { get; set; }
|
||||
/// <summary>
|
||||
/// 是否可见。
|
||||
/// </summary>
|
||||
public bool? IsVisible { get; set; }
|
||||
/// <summary>
|
||||
/// 是否参与趋势展示。
|
||||
/// </summary>
|
||||
public bool? IsTrend { get; set; }
|
||||
/// <summary>
|
||||
/// 选项配置。
|
||||
/// </summary>
|
||||
public JsonElement Options { get; set; } = JsonDefaults.Array();
|
||||
/// <summary>
|
||||
/// 占位提示。
|
||||
/// </summary>
|
||||
public string? Placeholder { get; set; }
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
|
||||
public ScorelineFieldCommand ToCommand()
|
||||
@@ -326,19 +758,49 @@ public sealed class DirectScorelineFieldDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧分数线记录写入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectScorelineRecordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
/// <summary>
|
||||
/// 院校 ID。
|
||||
/// </summary>
|
||||
public Guid? SchoolId { get; set; }
|
||||
/// <summary>
|
||||
/// 专业 ID。
|
||||
/// </summary>
|
||||
public Guid? MajorId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 年份。
|
||||
/// </summary>
|
||||
[Range(1900, 3000)]
|
||||
public int Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 院校名称。
|
||||
/// </summary>
|
||||
public string? SchoolName { get; set; }
|
||||
/// <summary>
|
||||
/// 专业名称。
|
||||
/// </summary>
|
||||
public string? MajorName { get; set; }
|
||||
/// <summary>
|
||||
/// 动态字段值。
|
||||
/// </summary>
|
||||
public JsonElement FieldValues { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public ScorelineRecordCommand ToCommand()
|
||||
@@ -356,21 +818,66 @@ public sealed class DirectScorelineRecordDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧视频写入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectVideoDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public required string Title { get; set; }
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
/// <summary>
|
||||
/// 视频地址。
|
||||
/// </summary>
|
||||
public string? VideoUrl { get; set; }
|
||||
/// <summary>
|
||||
/// 缩略图地址。
|
||||
/// </summary>
|
||||
public string? ThumbnailUrl { get; set; }
|
||||
/// <summary>
|
||||
/// 时长,单位为秒。
|
||||
/// </summary>
|
||||
public int? DurationSeconds { get; set; }
|
||||
/// <summary>
|
||||
/// 知识标签。
|
||||
/// </summary>
|
||||
public JsonElement KnowledgeTags { get; set; } = JsonDefaults.Array();
|
||||
/// <summary>
|
||||
/// 是否通用视频。
|
||||
/// </summary>
|
||||
public bool? IsGeneral { get; set; }
|
||||
/// <summary>
|
||||
/// 难度。
|
||||
/// </summary>
|
||||
public int? Difficulty { get; set; }
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public VideoExplanationCommand ToCommand()
|
||||
@@ -393,13 +900,34 @@ public sealed class DirectVideoDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧题目视频绑定请求。
|
||||
/// </summary>
|
||||
public sealed class DirectQuestionVideoDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid QuestionId { get; set; }
|
||||
/// <summary>
|
||||
/// 视频 ID。
|
||||
/// </summary>
|
||||
public Guid VideoId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 视频类型。
|
||||
/// </summary>
|
||||
public string? VideoType { get; set; }
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public QuestionVideoCommand ToCommand()
|
||||
@@ -408,28 +936,94 @@ public sealed class DirectQuestionVideoDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧运营内容写入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectOperationContentDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
/// <summary>
|
||||
/// 院校 ID。
|
||||
/// </summary>
|
||||
public Guid? SchoolId { get; set; }
|
||||
/// <summary>
|
||||
/// 历史系统 ID。
|
||||
/// </summary>
|
||||
public string? LegacyId { get; set; }
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
public string? Title { get; set; }
|
||||
/// <summary>
|
||||
/// 副标题。
|
||||
/// </summary>
|
||||
public string? Subtitle { get; set; }
|
||||
/// <summary>
|
||||
/// 内容。
|
||||
/// </summary>
|
||||
public string? Content { get; set; }
|
||||
/// <summary>
|
||||
/// 问题。
|
||||
/// </summary>
|
||||
public string? Question { get; set; }
|
||||
/// <summary>
|
||||
/// 答案。
|
||||
/// </summary>
|
||||
public string? Answer { get; set; }
|
||||
/// <summary>
|
||||
/// 链接地址。
|
||||
/// </summary>
|
||||
public string? Link { get; set; }
|
||||
/// <summary>
|
||||
/// 按钮文案。
|
||||
/// </summary>
|
||||
public string? ButtonText { get; set; }
|
||||
/// <summary>
|
||||
/// 按钮链接。
|
||||
/// </summary>
|
||||
public string? ButtonLink { get; set; }
|
||||
/// <summary>
|
||||
/// 背景颜色。
|
||||
/// </summary>
|
||||
public string? BackgroundColor { get; set; }
|
||||
/// <summary>
|
||||
/// 边框颜色。
|
||||
/// </summary>
|
||||
public string? BorderColor { get; set; }
|
||||
/// <summary>
|
||||
/// 考试名称。
|
||||
/// </summary>
|
||||
public string? ExamName { get; set; }
|
||||
/// <summary>
|
||||
/// 考试时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExamAt { get; set; }
|
||||
/// <summary>
|
||||
/// 考试类型。
|
||||
/// </summary>
|
||||
public string? ExamType { get; set; }
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
/// <summary>
|
||||
/// 显示顺序。
|
||||
/// </summary>
|
||||
public int? Order { get; set; }
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool? IsActive { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public OperationContentCommand ToCommand()
|
||||
@@ -459,27 +1053,90 @@ public sealed class DirectOperationContentDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧内容导入请求。
|
||||
/// </summary>
|
||||
public sealed class DirectImportDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 来源格式。
|
||||
/// </summary>
|
||||
public string? SourceFormat { get; set; }
|
||||
/// <summary>
|
||||
/// 来源名称。
|
||||
/// </summary>
|
||||
public string? SourceName { get; set; }
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
/// <summary>
|
||||
/// 题库 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionBankId { get; set; }
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
public Guid? CollectionId { get; set; }
|
||||
/// <summary>
|
||||
/// 是否异步执行。
|
||||
/// </summary>
|
||||
public bool? Async { get; set; }
|
||||
/// <summary>
|
||||
/// 数据项列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<JsonElement>? Items { get; set; }
|
||||
/// <summary>
|
||||
/// 单元数据列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<JsonElement>? Units { get; set; }
|
||||
/// <summary>
|
||||
/// 单词数据列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<JsonElement>? Words { get; set; }
|
||||
/// <summary>
|
||||
/// 科目数据列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<JsonElement>? Subjects { get; set; }
|
||||
/// <summary>
|
||||
/// 条目数据列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<JsonElement>? Entries { get; set; }
|
||||
/// <summary>
|
||||
/// 字段数据列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<JsonElement>? Fields { get; set; }
|
||||
/// <summary>
|
||||
/// 院校数据列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<JsonElement>? Schools { get; set; }
|
||||
/// <summary>
|
||||
/// 专业数据列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<JsonElement>? Majors { get; set; }
|
||||
/// <summary>
|
||||
/// 记录数据列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<JsonElement>? Records { get; set; }
|
||||
/// <summary>
|
||||
/// 视频数据列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<JsonElement>? Videos { get; set; }
|
||||
|
||||
public SimpleImportCommand ToCommand(string importType, bool dryRun)
|
||||
@@ -513,7 +1170,13 @@ public sealed class DirectImportDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理侧内容导入任务查询请求。
|
||||
/// </summary>
|
||||
public sealed class DirectImportJobDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务 ID。
|
||||
/// </summary>
|
||||
public Guid JobId { get; set; }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ namespace Tiku.Api.Contracts;
|
||||
/// <param name="Status">服务状态,例如 ok。</param>
|
||||
/// <param name="Service">服务名称。</param>
|
||||
/// <param name="CheckedAt">检查时间。</param>
|
||||
/// <summary>
|
||||
/// 健康检查Response请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record HealthResponseDto(
|
||||
string Status,
|
||||
string Service,
|
||||
|
||||
@@ -7,14 +7,26 @@ using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 学习Limit查询参数。
|
||||
/// </summary>
|
||||
public sealed class LearningLimitQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单元 ID。
|
||||
/// </summary>
|
||||
public Guid? UnitId { get; set; }
|
||||
|
||||
public LearningLimitFilter ToFilter()
|
||||
@@ -23,18 +35,36 @@ public sealed class LearningLimitQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 练习会话查询参数。
|
||||
/// </summary>
|
||||
public sealed class PracticeSessionQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 练习会话 ID。
|
||||
/// </summary>
|
||||
public Guid? PracticeSessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 练习蓝图 ID。
|
||||
/// </summary>
|
||||
public Guid? BlueprintId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
@@ -49,33 +79,69 @@ public sealed class PracticeSessionQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建练习会话请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreatePracticeSessionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Mode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? TargetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标 ID。
|
||||
/// </summary>
|
||||
public Guid? TargetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 练习蓝图 ID。
|
||||
/// </summary>
|
||||
public Guid? BlueprintId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
public Guid? CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? QuestionLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 时长,单位为分钟。
|
||||
/// </summary>
|
||||
[Range(1, 1440)]
|
||||
public int? DurationMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总分。
|
||||
/// </summary>
|
||||
[Range(typeof(decimal), "0", "99999")]
|
||||
public decimal? TotalScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public PracticeSessionCommand ToCommand()
|
||||
@@ -95,8 +161,14 @@ public sealed class CreatePracticeSessionDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交练习会话请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class SubmitPracticeSessionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 练习会话 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid PracticeSessionId { get; set; }
|
||||
|
||||
@@ -106,16 +178,31 @@ public sealed class SubmitPracticeSessionDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交答案请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class SubmitAnswerDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 会话题目 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid SessionQuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已选选项。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string>? SelectedOptions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文字答案。
|
||||
/// </summary>
|
||||
[StringLength(10000)]
|
||||
public string? AnswerText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 主观题自评是否正确。
|
||||
/// </summary>
|
||||
public bool? SelfJudgedCorrect { get; set; }
|
||||
|
||||
public SubmitAnswerCommand ToCommand()
|
||||
@@ -128,14 +215,26 @@ public sealed class SubmitAnswerDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 题目Action请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class QuestionActionDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public QuestionSource Source { get; set; } = QuestionSource.Tenant;
|
||||
|
||||
/// <summary>
|
||||
/// 是否收藏。
|
||||
/// </summary>
|
||||
public bool? Favorite { get; set; }
|
||||
|
||||
public QuestionActionCommand ToCommand()
|
||||
@@ -144,20 +243,38 @@ public sealed class QuestionActionDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单词Progress请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class WordProgressDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 单词 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid WordId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 答对数量变化。
|
||||
/// </summary>
|
||||
[Range(0, 100)]
|
||||
public int? CorrectDelta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 答错数量变化。
|
||||
/// </summary>
|
||||
[Range(0, 100)]
|
||||
public int? WrongDelta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下次复习时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? NextReviewAt { get; set; }
|
||||
|
||||
public WordProgressCommand ToCommand()
|
||||
@@ -171,13 +288,25 @@ public sealed class WordProgressDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收藏单词请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class FavoriteWordDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 单词 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid WordId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否收藏。
|
||||
/// </summary>
|
||||
public bool? Favorite { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Note { get; set; }
|
||||
|
||||
@@ -187,14 +316,26 @@ public sealed class FavoriteWordDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单词Review请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class WordReviewDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 单词 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid WordId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结果。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Result { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下次复习时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? NextReviewAt { get; set; }
|
||||
|
||||
public WordReviewCommand ToCommand()
|
||||
|
||||
@@ -9,88 +9,201 @@ using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 平台管理端查询参数。
|
||||
/// </summary>
|
||||
public sealed class PlatformAdminQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 搜索关键字。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Search { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public PlatformAdminQuery ToQuery() => new(Status, Search, Limit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建平台租户请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreatePlatformTenantDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 短编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 法定名称。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? LegalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public TenantStatus Status { get; set; } = TenantStatus.Active;
|
||||
/// <summary>
|
||||
/// 账务状态。
|
||||
/// </summary>
|
||||
public BillingStatus BillingStatus { get; set; } = BillingStatus.Trial;
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public CreatePlatformTenantCommand ToCommand() => new(Slug, Name, LegalName, Status, BillingStatus, Metadata);
|
||||
/// <summary>
|
||||
/// 租户负责人邮箱。
|
||||
/// </summary>
|
||||
[EmailAddress, StringLength(320)]
|
||||
public string? OwnerEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户负责人手机号。
|
||||
/// </summary>
|
||||
[Phone, StringLength(32)]
|
||||
public string? OwnerPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户负责人姓名。
|
||||
/// </summary>
|
||||
[Required, StringLength(100)]
|
||||
public string OwnerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 临时密码。
|
||||
/// </summary>
|
||||
[Required, StringLength(200, MinimumLength = 12)]
|
||||
public string TemporaryPassword { get; set; } = string.Empty;
|
||||
|
||||
public CreatePlatformTenantCommand ToCommand() => new(
|
||||
Slug, Name, LegalName, Status, BillingStatus, Metadata,
|
||||
OwnerEmail, OwnerPhone, OwnerName, TemporaryPassword);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新平台租户状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdatePlatformTenantStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public TenantStatus Status { get; set; } = TenantStatus.Active;
|
||||
/// <summary>
|
||||
/// 账务状态。
|
||||
/// </summary>
|
||||
public BillingStatus BillingStatus { get; set; } = BillingStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 原因。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public UpdatePlatformTenantStatusCommand ToCommand() => new(TenantId, Status, BillingStatus, Reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新平台租户账务资料请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertPlatformTenantBillingProfileDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账务名称。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? BillingName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 税号。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TaxId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系人姓名。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? ContactName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系人手机号。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系人邮箱。
|
||||
/// </summary>
|
||||
[StringLength(320)]
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账务地址。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? BillingAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发票抬头。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? InvoiceTitle { get; set; }
|
||||
|
||||
public TenantInvoiceTitleType? InvoiceType { get; set; }
|
||||
/// <summary>
|
||||
/// 发票类型。
|
||||
/// </summary>
|
||||
public TenantBillingInvoiceTitleType? InvoiceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开户银行。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? BankName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 脱敏银行账号。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? BankAccountMasked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformTenantBillingProfileCommand ToCommand() => new(
|
||||
@@ -108,143 +221,180 @@ public sealed class UpsertPlatformTenantBillingProfileDto
|
||||
Metadata);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformSubscriptionDto
|
||||
{
|
||||
[Required]
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string PlanCode { get; set; } = string.Empty;
|
||||
|
||||
public TenantSubscriptionStatus Status { get; set; } = TenantSubscriptionStatus.Active;
|
||||
public DateTimeOffset? StartsAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
[StringLength(32)]
|
||||
public string? BillingCycle { get; set; }
|
||||
|
||||
[Range(0, int.MaxValue)]
|
||||
public int AmountCents { get; set; }
|
||||
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformSubscriptionCommand ToCommand() => new(
|
||||
TenantId,
|
||||
PlanCode,
|
||||
Status,
|
||||
StartsAt,
|
||||
ExpiresAt,
|
||||
BillingCycle,
|
||||
AmountCents,
|
||||
Metadata);
|
||||
}
|
||||
|
||||
public sealed class ReplacePlatformPlanModulesDto
|
||||
{
|
||||
[Required]
|
||||
public IReadOnlyCollection<string> ModuleCodes { get; set; } = [];
|
||||
|
||||
public ReplacePlatformPlanModulesCommand ToCommand(string planCode) => new(planCode, ModuleCodes);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformTenantModuleOverrideDto
|
||||
{
|
||||
public TenantModuleOverrideMode Mode { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(1000, MinimumLength = 1)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public UpsertPlatformTenantModuleOverrideCommand ToCommand(Guid tenantId, string moduleCode) =>
|
||||
new(tenantId, moduleCode, Mode, ExpiresAt, Reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新平台Staff请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertPlatformStaffDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱。
|
||||
/// </summary>
|
||||
[StringLength(320)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public UserStatus Status { get; set; } = UserStatus.Active;
|
||||
/// <summary>
|
||||
/// 角色 ID 列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Guid> RoleIds { get; set; } = [];
|
||||
|
||||
public UpsertPlatformStaffCommand ToCommand() => new(UserId, Email, Phone, Name, Status, RoleIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新平台Staff状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdatePlatformStaffStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public UserStatus Status { get; set; } = UserStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 原因。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public UpdatePlatformStaffStatusCommand ToCommand() => new(UserId, Status, Reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新平台审计Alert状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdatePlatformAuditAlertStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 告警 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid AlertId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public PlatformAuditAlertStatus Status { get; set; } = PlatformAuditAlertStatus.Acknowledged;
|
||||
|
||||
/// <summary>
|
||||
/// 处理备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? ResolutionNote { get; set; }
|
||||
|
||||
public UpdatePlatformAuditAlertStatusCommand ToCommand() => new(AlertId, Status, ResolutionNote);
|
||||
}
|
||||
|
||||
public sealed class UpsertPlatformDunningChannelDto
|
||||
/// <summary>
|
||||
/// 新增或更新平台DunningChannel请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertPlatformBillingDunningChannelDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 通知渠道 ID。
|
||||
/// </summary>
|
||||
public Guid? ChannelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string ChannelCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
public PlatformDunningProvider Provider { get; set; } = PlatformDunningProvider.Generic;
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
public PlatformBillingDunningProvider Provider { get; set; } = PlatformBillingDunningProvider.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// Webhook 地址。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(2048)]
|
||||
public string WebhookUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 密钥引用。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提醒类型列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> ReminderTypes { get; set; } = ["overdue", "final_notice"];
|
||||
/// <summary>
|
||||
/// 提醒渠道列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> ReminderChannels { get; set; } = ["internal"];
|
||||
|
||||
/// <summary>
|
||||
/// 最低提醒级别。
|
||||
/// </summary>
|
||||
[Range(1, 20)]
|
||||
public int MinReminderLevel { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID 列表。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<Guid> TenantIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 超时时长,单位为秒。
|
||||
/// </summary>
|
||||
[Range(1, 60)]
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPlatformDunningChannelCommand ToCommand() => new(
|
||||
public UpsertPlatformBillingDunningChannelCommand ToCommand() => new(
|
||||
ChannelId,
|
||||
ChannelCode,
|
||||
Name,
|
||||
@@ -261,24 +411,42 @@ public sealed class UpsertPlatformDunningChannelDto
|
||||
Metadata);
|
||||
}
|
||||
|
||||
public sealed class DisablePlatformDunningChannelDto
|
||||
/// <summary>
|
||||
/// 禁用平台DunningChannel请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class DisablePlatformBillingDunningChannelDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 通知渠道 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid ChannelId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原因。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public DisablePlatformDunningChannelCommand ToCommand() => new(ChannelId, Reason);
|
||||
public DisablePlatformBillingDunningChannelCommand ToCommand() => new(ChannelId, Reason);
|
||||
}
|
||||
|
||||
public sealed class RetryPlatformDunningEventDto
|
||||
/// <summary>
|
||||
/// 重试平台Dunning事件请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class RetryPlatformBillingDunningEventDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 事件 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid EventId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原因。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
public RetryPlatformDunningEventCommand ToCommand() => new(EventId, Reason);
|
||||
public RetryPlatformBillingDunningEventCommand ToCommand() => new(EventId, Reason);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,26 @@ using Tiku.Application.Points;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 积分查询参数。
|
||||
/// </summary>
|
||||
public sealed class PointQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
public PointLimitQuery ToQuery()
|
||||
@@ -19,15 +31,27 @@ public sealed class PointQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 领取积分任务请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ClaimPointTaskDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务键。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string TaskKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 来源类型。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? SourceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源 ID。
|
||||
/// </summary>
|
||||
public Guid? SourceId { get; set; }
|
||||
|
||||
public ClaimPointTaskCommand ToCommand()
|
||||
@@ -36,8 +60,14 @@ public sealed class ClaimPointTaskDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建积分兑换订单请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreatePointExchangeOrderDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 兑换项 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid ItemId { get; set; }
|
||||
|
||||
|
||||
@@ -4,47 +4,107 @@ using Tiku.Application.Profile;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 资料查询参数。
|
||||
/// </summary>
|
||||
public sealed class ProfileQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 最近记录数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 50)]
|
||||
public int? RecentLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 20)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含锁定资源。
|
||||
/// </summary>
|
||||
public bool IncludeLocked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源类型。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? SourceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? From { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? To { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新资料请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdateProfileDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 头像预设。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? AvatarPreset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
/// <summary>
|
||||
/// 已选择院校 ID。
|
||||
/// </summary>
|
||||
public Guid? SelectedSchoolId { get; set; }
|
||||
/// <summary>
|
||||
/// 已选择专业 ID。
|
||||
/// </summary>
|
||||
public Guid? SelectedMajorId { get; set; }
|
||||
/// <summary>
|
||||
/// 统计数据。
|
||||
/// </summary>
|
||||
public JsonElement? Stats { get; set; }
|
||||
/// <summary>
|
||||
/// 进度数据。
|
||||
/// </summary>
|
||||
public JsonElement? Progress { get; set; }
|
||||
/// <summary>
|
||||
/// 模块选择数据。
|
||||
/// </summary>
|
||||
public JsonElement? ModuleSelections { get; set; }
|
||||
/// <summary>
|
||||
/// 最近活动数据。
|
||||
/// </summary>
|
||||
public JsonElement? RecentActivities { get; set; }
|
||||
|
||||
public UpdateProfileCommand ToCommand()
|
||||
@@ -62,13 +122,22 @@ public sealed class UpdateProfileDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通知状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class NotificationStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 通知 ID 列表。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
[MaxLength(100)]
|
||||
public IReadOnlyCollection<Guid> NotificationIds { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
@@ -78,30 +147,60 @@ public sealed class NotificationStatusDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交反馈请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class SubmitFeedbackDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(5000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 优先级。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 联系方式。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? Contact { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 附件列表。
|
||||
/// </summary>
|
||||
public JsonElement Attachments { get; set; } = JsonSerializer.SerializeToElement(Array.Empty<object>());
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonSerializer.SerializeToElement(new { });
|
||||
|
||||
public SubmitFeedbackCommand ToCommand()
|
||||
|
||||
@@ -4,40 +4,82 @@ using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 题目题库查询参数。
|
||||
/// </summary>
|
||||
public sealed class QuestionBankQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题库 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionBankId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分类 ID。
|
||||
/// </summary>
|
||||
public Guid? CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 节点 ID。
|
||||
/// </summary>
|
||||
public Guid? NodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题集 ID。
|
||||
/// </summary>
|
||||
public Guid? CollectionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
public QuestionSource? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目 ID 列表,支持逗号分隔;最多取前 300 个。
|
||||
/// 题目 ID 列表。
|
||||
/// </summary>
|
||||
public string? QuestionIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
|
||||
@@ -5,17 +5,32 @@ using Tiku.Application.Growth;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 推荐租户查询参数。
|
||||
/// </summary>
|
||||
public sealed class ReferralTenantQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推荐邀请码请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ReferralInviteDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 落地页路径。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? LandingPath { get; set; }
|
||||
|
||||
@@ -25,11 +40,20 @@ public sealed class ReferralInviteDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析推荐请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ResolveReferralDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Code { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
@@ -39,22 +63,43 @@ public sealed class ResolveReferralDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录推荐事件请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class TrackReferralEventDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 推荐码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string RefCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 事件类型。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? EventType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标用户 ID。
|
||||
/// </summary>
|
||||
public Guid? TargetUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
@@ -64,15 +109,27 @@ public sealed class TrackReferralEventDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 绑定推荐请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class BindReferralDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 推荐码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string RefCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
public BindReferralCommand ToCommand()
|
||||
@@ -81,20 +138,38 @@ public sealed class BindReferralDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推荐二维码请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ReferralQrcodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 场景参数。
|
||||
/// </summary>
|
||||
[StringLength(128)]
|
||||
public string? Scene { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Provider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 二维码地址。
|
||||
/// </summary>
|
||||
[StringLength(2048)]
|
||||
public string? QrcodeUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
public ReferralQrcodeCommand ToCommand()
|
||||
@@ -103,10 +178,19 @@ public sealed class ReferralQrcodeDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推荐统计查询参数。
|
||||
/// </summary>
|
||||
public sealed class ReferralStatsQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 推荐人用户 ID。
|
||||
/// </summary>
|
||||
public Guid? ReferrerUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
@@ -116,19 +200,37 @@ public sealed class ReferralStatsQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推荐Conversion查询参数。
|
||||
/// </summary>
|
||||
public sealed class ReferralConversionQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 推荐人用户 ID。
|
||||
/// </summary>
|
||||
public Guid? ReferrerUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 开始日期,格式为 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||
public string? StartDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 结束日期,格式为 yyyy-MM-dd。
|
||||
/// </summary>
|
||||
[RegularExpression("^\\d{4}-\\d{2}-\\d{2}$")]
|
||||
public string? EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 天数。
|
||||
/// </summary>
|
||||
[Range(1, 365)]
|
||||
public int? Days { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 100)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
@@ -150,19 +252,37 @@ public sealed class ReferralConversionQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 人工绑定推荐请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class ManualBindReferralDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 学生用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid StudentUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 推荐人用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid ReferrerUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否强制执行。
|
||||
/// </summary>
|
||||
public bool Force { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
public ManualBindReferralCommand ToCommand()
|
||||
@@ -171,8 +291,14 @@ public sealed class ManualBindReferralDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推荐团队查询参数。
|
||||
/// </summary>
|
||||
public sealed class ReferralTeamQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 上级成员用户 ID。
|
||||
/// </summary>
|
||||
public Guid? LeaderUserId { get; set; }
|
||||
|
||||
public ReferralTeamQuery ToQuery()
|
||||
@@ -181,19 +307,37 @@ public sealed class ReferralTeamQueryDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新推荐团队请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertReferralTeamDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 成员用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid MemberUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 上级成员用户 ID。
|
||||
/// </summary>
|
||||
public Guid? LeaderUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关系类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? RelationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement? Metadata { get; set; }
|
||||
|
||||
public UpsertReferralTeamCommand ToCommand()
|
||||
|
||||
151
Tiku.Api/Contracts/SaasBillingDtos.cs
Normal file
151
Tiku.Api/Contracts/SaasBillingDtos.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新SaaS功能请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record UpsertSaasFeatureDto(
|
||||
Guid? Id,
|
||||
[Required, MaxLength(120)] string Code,
|
||||
[Required, MaxLength(200)] string Name,
|
||||
[Required, MaxLength(100)] string Category,
|
||||
[MaxLength(1000)] string? Description,
|
||||
[Range(0, int.MaxValue)] int ReferencePriceCents,
|
||||
[Required, MaxLength(10)] string Currency,
|
||||
SaasFeatureStatus Status,
|
||||
int SortOrder)
|
||||
{
|
||||
public UpsertSaasFeatureCommand ToCommand() => new(Id, Code, Name, Category, Description, ReferencePriceCents, Currency, Status, SortOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新SaaS套餐请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record UpsertSaasOfferingDto(
|
||||
Guid? Id,
|
||||
[Required, MaxLength(120)] string Code,
|
||||
[Required, MaxLength(200)] string Name,
|
||||
SaasOfferingType Type,
|
||||
SaasOfferingStatus Status,
|
||||
[MaxLength(1000)] string? Description,
|
||||
int SortOrder)
|
||||
{
|
||||
public UpsertSaasOfferingCommand ToCommand() => new(Id, Code, Name, Type, Status, Description, SortOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新 SaaS 功能额度定义。
|
||||
/// </summary>
|
||||
public sealed record UpsertSaasFeatureLimitDto(
|
||||
Guid? Id,
|
||||
[Required, MaxLength(120)] string MetricCode,
|
||||
[Required, MaxLength(120)] string FeatureCode,
|
||||
[Required, MaxLength(200)] string Name,
|
||||
[Required, MaxLength(50)] string Unit,
|
||||
SaasFeatureLimitKind Kind,
|
||||
[Range(1, 100)] int WarningPercent,
|
||||
bool IsHardLimit)
|
||||
{
|
||||
public UpsertSaasFeatureLimitCommand ToCommand() => new(
|
||||
Id, MetricCode, FeatureCode, Name, Unit, Kind, WarningPercent, IsHardLimit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新SaaS套餐版本请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record UpsertSaasOfferingVersionDto(
|
||||
Guid? Id,
|
||||
Guid OfferingId,
|
||||
PlatformBillingCycle BillingCycle,
|
||||
[Range(0, int.MaxValue)] int OriginalAmountCents,
|
||||
[Range(0, int.MaxValue)] int AmountCents,
|
||||
[Required, MaxLength(10)] string Currency,
|
||||
DateTimeOffset? EffectiveAt,
|
||||
IReadOnlyCollection<string>? FeatureCodes,
|
||||
IReadOnlyDictionary<string, long>? Limits,
|
||||
JsonElement Metadata)
|
||||
{
|
||||
public UpsertSaasOfferingVersionCommand ToCommand() => new(
|
||||
Id, OfferingId, BillingCycle, OriginalAmountCents, AmountCents, Currency, EffectiveAt,
|
||||
FeatureCodes ?? [], Limits ?? new Dictionary<string, long>(), Metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建平台账务报价请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record CreatePlatformBillingQuoteDto(
|
||||
Guid BaseOfferingVersionId,
|
||||
IReadOnlyCollection<Guid>? AddOnOfferingVersionIds,
|
||||
PlatformBillingOrderPurpose Purpose,
|
||||
[Required, MaxLength(200)] string IdempotencyKey)
|
||||
{
|
||||
public CreatePlatformBillingQuoteCommand ToCommand() => new(BaseOfferingVersionId, AddOnOfferingVersionIds ?? [], Purpose, IdempotencyKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建平台账务订单请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record CreatePlatformBillingOrderDto(Guid QuoteId, [Required, MaxLength(200)] string IdempotencyKey)
|
||||
{
|
||||
public CreatePlatformBillingOrderCommand ToCommand() => new(QuoteId, IdempotencyKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建平台账务支付请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record CreatePlatformBillingPaymentDto(
|
||||
[Required, MaxLength(50)] string Provider,
|
||||
[Required, MaxLength(50)] string Method,
|
||||
[Required, MaxLength(200)] string IdempotencyKey,
|
||||
string? OpenId,
|
||||
string? ReturnUrl,
|
||||
string? QuitUrl)
|
||||
{
|
||||
public CreatePlatformBillingPaymentCommand ToCommand(string orderNo) =>
|
||||
new(orderNo, Provider, Method, IdempotencyKey, OpenId, ReturnUrl, QuitUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 变更租户订阅请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record ChangeTenantSubscriptionDto(
|
||||
Guid BaseOfferingVersionId,
|
||||
IReadOnlyCollection<Guid>? AddOnOfferingVersionIds,
|
||||
[Required, MaxLength(200)] string IdempotencyKey)
|
||||
{
|
||||
public ChangeTenantSubscriptionCommand ToCommand() => new(BaseOfferingVersionId, AddOnOfferingVersionIds ?? []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent租户账务请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record IdempotentTenantBillingDto([Required, MaxLength(200)] string IdempotencyKey);
|
||||
|
||||
/// <summary>
|
||||
/// 确认人工平台支付请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record ConfirmManualPlatformPaymentDto(
|
||||
Guid PaymentId,
|
||||
string? ProviderTradeNo,
|
||||
DateTimeOffset? PaidAt,
|
||||
[Required, MaxLength(1000)] string Reason)
|
||||
{
|
||||
public ConfirmManualPaymentCommand ToCommand() => new(PaymentId, ProviderTradeNo, PaidAt, Reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新租户功能覆盖规则请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record UpsertTenantFeatureOverrideDto(
|
||||
Guid TenantId,
|
||||
[Required, MaxLength(120)] string FeatureCode,
|
||||
TenantFeatureOverrideMode Mode,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
[Required, MaxLength(1000)] string Reason)
|
||||
{
|
||||
public UpsertTenantFeatureOverrideCommand ToCommand() => new(TenantId, FeatureCode, Mode, ExpiresAt, Reason);
|
||||
}
|
||||
@@ -3,27 +3,57 @@ using Tiku.Application.Scoreline;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 分数线查询参数。
|
||||
/// </summary>
|
||||
public sealed class ScorelineQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
/// <summary>
|
||||
/// 院校 ID。
|
||||
/// </summary>
|
||||
public Guid? SchoolId { get; set; }
|
||||
/// <summary>
|
||||
/// 专业 ID。
|
||||
/// </summary>
|
||||
public Guid? MajorId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 年份。
|
||||
/// </summary>
|
||||
[Range(1900, 3000)]
|
||||
public int? Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码。
|
||||
/// </summary>
|
||||
[Range(1, 10000)]
|
||||
public int? Page { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每页数量。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? PageSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 2000)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
|
||||
@@ -3,28 +3,61 @@ using Tiku.Application.StudyContent;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Study内容查询参数。
|
||||
/// </summary>
|
||||
public sealed class StudyContentQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? TenantCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 单元 ID。
|
||||
/// </summary>
|
||||
public Guid? UnitId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 章节 ID。
|
||||
/// </summary>
|
||||
public Guid? ChapterId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容入口 ID。
|
||||
/// </summary>
|
||||
public Guid? EntryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 内容节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ContentNodeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含正文内容。
|
||||
/// </summary>
|
||||
public bool IncludeContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 2000)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
|
||||
@@ -7,17 +7,41 @@ using Tiku.Domain.Content;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 创建分类节点请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreateTaxonomyNodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 父节点 ID。
|
||||
/// </summary>
|
||||
public Guid? ParentId { get; set; }
|
||||
/// <summary>
|
||||
/// 父级来源。
|
||||
/// </summary>
|
||||
public QuestionSource? ParentSource { get; set; }
|
||||
/// <summary>
|
||||
/// 节点Type。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public TaxonomyNodeType NodeType { get; set; }
|
||||
/// <summary>
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
[Required, StringLength(100)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required, StringLength(300)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public CreateTaxonomyNodeCommand ToCommand() => new(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,41 +7,83 @@ using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 租户交易查询参数。
|
||||
/// </summary>
|
||||
public sealed class TenantCommerceQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Provider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新支付账号请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertPaymentAccountDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模式。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Mode { get; set; } = "TenantCollect";
|
||||
|
||||
/// <summary>
|
||||
/// 显示名称。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public TenantExternalProviderStatus Status { get; set; } = TenantExternalProviderStatus.Disabled;
|
||||
|
||||
/// <summary>
|
||||
/// 密钥引用。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string? SecretRef { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 优先级。
|
||||
/// </summary>
|
||||
public int? Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 公开配置内容。
|
||||
/// </summary>
|
||||
public JsonElement ConfigPublic { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPaymentAccountCommand ToCommand()
|
||||
@@ -50,25 +92,49 @@ public sealed class UpsertPaymentAccountDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新租户Secret请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertTenantSecretDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 用途。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(80)]
|
||||
public string Purpose { get; set; } = "payment";
|
||||
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 密钥键。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(120)]
|
||||
public string SecretKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 密钥引用。
|
||||
/// </summary>
|
||||
[StringLength(300)]
|
||||
public string SecretRef { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public TenantSecretStatus Status { get; set; } = TenantSecretStatus.Active;
|
||||
/// <summary>
|
||||
/// 密钥载荷。
|
||||
/// </summary>
|
||||
public JsonElement SecretPayload { get; set; } = JsonDefaults.Object();
|
||||
/// <summary>
|
||||
/// 过期时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
public UpsertTenantSecretCommand ToCommand()
|
||||
@@ -84,32 +150,62 @@ public sealed class UpsertTenantSecretDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建编码批次请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreateCodeBatchDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 总数量。
|
||||
/// </summary>
|
||||
[Range(1, 1000)]
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 天数。
|
||||
/// </summary>
|
||||
[Range(1, 3650)]
|
||||
public int Days { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 售卖类型。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? SaleType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 默认单价,单位为分。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? DefaultUnitPriceCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 成本价,单位为分。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? CostPriceCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Remark { get; set; }
|
||||
|
||||
@@ -128,15 +224,27 @@ public sealed class CreateCodeBatchDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 核销激活码编码请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class RedeemActivationCodeDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
public RedeemActivationCodeCommand ToCommand()
|
||||
@@ -145,34 +253,76 @@ public sealed class RedeemActivationCodeDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新积分任务请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertPointTaskDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 任务键。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string TaskKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 标题。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 任务类型。
|
||||
/// </summary>
|
||||
public PointActivityTaskType TaskType { get; set; } = PointActivityTaskType.Manual;
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public PointActivityTaskStatus Status { get; set; } = PointActivityTaskStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 积分数量。
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int Points { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每用户最多领取次数。
|
||||
/// </summary>
|
||||
[Range(1, 1000)]
|
||||
public int MaxClaimsPerUser { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 开始时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartsAt { get; set; }
|
||||
/// <summary>
|
||||
/// 结束时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? EndsAt { get; set; }
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
/// <summary>
|
||||
/// 规则配置。
|
||||
/// </summary>
|
||||
public JsonElement Rules { get; set; } = JsonDefaults.Object();
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPointTaskCommand ToCommand()
|
||||
@@ -194,38 +344,86 @@ public sealed class UpsertPointTaskDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新积分兑换Item请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertPointExchangeItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
/// <summary>
|
||||
/// 地区 ID。
|
||||
/// </summary>
|
||||
public Guid? RegionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换项键。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string ItemKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 说明。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兑换项类型。
|
||||
/// </summary>
|
||||
public PointExchangeItemType ItemType { get; set; } = PointExchangeItemType.Entitlement;
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public PointExchangeItemStatus Status { get; set; } = PointExchangeItemStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 所需积分。
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int PointsCost { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 库存数量。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? Stock { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 天数。
|
||||
/// </summary>
|
||||
[Range(0, 3650)]
|
||||
public int? Days { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值。
|
||||
/// </summary>
|
||||
public int SortOrder { get; set; }
|
||||
/// <summary>
|
||||
/// 开始时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? StartsAt { get; set; }
|
||||
/// <summary>
|
||||
/// 结束时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? EndsAt { get; set; }
|
||||
/// <summary>
|
||||
/// 履约载荷。
|
||||
/// </summary>
|
||||
public JsonElement FulfillmentPayload { get; set; } = JsonDefaults.Object();
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public UpsertPointExchangeItemCommand ToCommand()
|
||||
@@ -249,20 +447,41 @@ public sealed class UpsertPointExchangeItemDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建退款Request请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreateRefundRequestDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid OrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 支付记录 ID。
|
||||
/// </summary>
|
||||
public Guid? PaymentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 金额,单位为分。
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int AmountCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原因。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 权益动作。
|
||||
/// </summary>
|
||||
public RefundEntitlementAction EntitlementAction { get; set; } = RefundEntitlementAction.RevokeOnSuccess;
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public CreateRefundRequestCommand ToCommand()
|
||||
@@ -271,16 +490,31 @@ public sealed class CreateRefundRequestDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新退款状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdateRefundStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 退款申请 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid RefundRequestId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public CommerceRefundStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原因。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道退款单号。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? ProviderRefundNo { get; set; }
|
||||
|
||||
@@ -290,23 +524,47 @@ public sealed class UpdateRefundStatusDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建对账批次请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreateReconciliationBatchDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 账单日期。
|
||||
/// </summary>
|
||||
public DateOnly BillDate { get; set; }
|
||||
/// <summary>
|
||||
/// 账单类型。
|
||||
/// </summary>
|
||||
public ReconciliationBillType BillType { get; set; } = ReconciliationBillType.Combined;
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
public ReconciliationSource Source { get; set; } = ReconciliationSource.ManualUpload;
|
||||
|
||||
/// <summary>
|
||||
/// 来源名称。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? SourceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源哈希。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(200)]
|
||||
public string SourceHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public CreateReconciliationBatchCommand ToCommand()
|
||||
@@ -315,17 +573,35 @@ public sealed class CreateReconciliationBatchDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新对账Issue请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdateReconciliationIssueDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 工单 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid IssueId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public ReconciliationIssueStatus Status { get; set; } = ReconciliationIssueStatus.Investigating;
|
||||
/// <summary>
|
||||
/// 处理类型。
|
||||
/// </summary>
|
||||
public ReconciliationResolutionType ResolutionType { get; set; } = ReconciliationResolutionType.None;
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 分配处理人。
|
||||
/// </summary>
|
||||
public Guid? AssignedTo { get; set; }
|
||||
|
||||
public UpdateReconciliationIssueCommand ToCommand()
|
||||
@@ -334,19 +610,37 @@ public sealed class UpdateReconciliationIssueDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预览对账导入请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class PreviewReconciliationImportDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 账单日期。
|
||||
/// </summary>
|
||||
public DateOnly BillDate { get; set; }
|
||||
/// <summary>
|
||||
/// 账单类型。
|
||||
/// </summary>
|
||||
public ReconciliationBillType BillType { get; set; } = ReconciliationBillType.Combined;
|
||||
|
||||
/// <summary>
|
||||
/// 来源名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(300)]
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 数据行列表。
|
||||
/// </summary>
|
||||
public JsonElement Rows { get; set; } = JsonDefaults.Array();
|
||||
|
||||
public PreviewReconciliationImportCommand ToPreviewCommand() => new(Provider, BillDate, BillType, SourceName, Rows);
|
||||
@@ -354,29 +648,68 @@ public sealed class PreviewReconciliationImportDto
|
||||
public ImportReconciliationCommand ToImportCommand() => new(Provider, BillDate, BillType, SourceName, Rows);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建调账凭证请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class CreateAdjustmentVoucherDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 工单 ID。
|
||||
/// </summary>
|
||||
public Guid? IssueId { get; set; }
|
||||
/// <summary>
|
||||
/// 批次 ID。
|
||||
/// </summary>
|
||||
public Guid? BatchId { get; set; }
|
||||
/// <summary>
|
||||
/// 兑换项 ID。
|
||||
/// </summary>
|
||||
public Guid? ItemId { get; set; }
|
||||
/// <summary>
|
||||
/// 订单 ID。
|
||||
/// </summary>
|
||||
public Guid? OrderId { get; set; }
|
||||
/// <summary>
|
||||
/// 支付记录 ID。
|
||||
/// </summary>
|
||||
public Guid? PaymentId { get; set; }
|
||||
/// <summary>
|
||||
/// 退款申请 ID。
|
||||
/// </summary>
|
||||
public Guid? RefundRequestId { get; set; }
|
||||
/// <summary>
|
||||
/// 方向。
|
||||
/// </summary>
|
||||
public CommerceAdjustmentDirection Direction { get; set; } = CommerceAdjustmentDirection.IncreaseRevenue;
|
||||
|
||||
/// <summary>
|
||||
/// 金额,单位为分。
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int AmountCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 币种。
|
||||
/// </summary>
|
||||
[StringLength(10)]
|
||||
public string? Currency { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 原因。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(1000)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 证明资产键。
|
||||
/// </summary>
|
||||
[StringLength(500)]
|
||||
public string? ProofAssetKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public CreateAdjustmentVoucherCommand ToCommand() => new(
|
||||
@@ -394,56 +727,110 @@ public sealed class CreateAdjustmentVoucherDto
|
||||
Metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新调账凭证状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdateAdjustmentVoucherStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 凭证 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid VoucherId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public CommerceAdjustmentVoucherStatus Status { get; set; } = CommerceAdjustmentVoucherStatus.PendingReview;
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Note { get; set; }
|
||||
|
||||
public UpdateAdjustmentVoucherStatusCommand ToCommand() => new(VoucherId, Status, Note);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RequestProviderBill任务请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class RequestProviderBillJobDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 服务提供方。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(50)]
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 账单日期。
|
||||
/// </summary>
|
||||
public DateOnly BillDate { get; set; }
|
||||
/// <summary>
|
||||
/// 账单类型。
|
||||
/// </summary>
|
||||
public ReconciliationBillType BillType { get; set; } = ReconciliationBillType.Combined;
|
||||
/// <summary>
|
||||
/// 计划运行时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? RunAfter { get; set; }
|
||||
|
||||
public RequestProviderBillJobCommand ToCommand() => new(Provider, BillDate, BillType, RunAfter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 退款通知请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class RefundNotificationDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 退款单号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string RefundNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 渠道退款单号。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? ProviderRefundNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public CommerceRefundStatus Status { get; set; } = CommerceRefundStatus.Succeeded;
|
||||
|
||||
/// <summary>
|
||||
/// 事件 ID。
|
||||
/// </summary>
|
||||
[StringLength(200)]
|
||||
public string? EventId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 任务载荷。
|
||||
/// </summary>
|
||||
public JsonElement Payload { get; set; } = JsonDefaults.Object();
|
||||
|
||||
public RefundNotificationCommand ToCommand(string provider) => new(provider, RefundNo, ProviderRefundNo, Status, EventId, Payload);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新积分兑换订单状态请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpdatePointExchangeOrderStatusDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 订单 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid OrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 状态。
|
||||
/// </summary>
|
||||
public PointExchangeOrderStatus Status { get; set; }
|
||||
|
||||
public UpdatePointExchangeOrderStatusCommand ToCommand()
|
||||
@@ -452,29 +839,62 @@ public sealed class UpdatePointExchangeOrderStatusDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增或更新租户优惠券请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class UpsertTenantCouponDto
|
||||
{
|
||||
/// <summary>
|
||||
/// ID。
|
||||
/// </summary>
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 套餐 ID。
|
||||
/// </summary>
|
||||
public Guid? PlanId { get; set; }
|
||||
/// <summary>
|
||||
/// 优惠类型。
|
||||
/// </summary>
|
||||
public DiscountType DiscountType { get; set; } = DiscountType.Fixed;
|
||||
|
||||
/// <summary>
|
||||
/// 优惠值。
|
||||
/// </summary>
|
||||
[Range(typeof(decimal), "0.01", "999999")]
|
||||
public decimal DiscountValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 有效期开始时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidFrom { get; set; }
|
||||
/// <summary>
|
||||
/// 有效期结束时间。
|
||||
/// </summary>
|
||||
public DateTimeOffset? ValidTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最大使用次数。
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int? MaxUses { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 来源。
|
||||
/// </summary>
|
||||
[StringLength(50)]
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 备注。
|
||||
/// </summary>
|
||||
[StringLength(1000)]
|
||||
public string? Remark { get; set; }
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@ namespace Tiku.Api.Contracts;
|
||||
public sealed class TenantResolveQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 要解析的访问域名,例如 student.example.com。本地开发也可以直接传 localhost。
|
||||
/// 访问域名。
|
||||
/// </summary>
|
||||
[StringLength(253)]
|
||||
[Description("要解析的访问域名,例如 student.example.com。本地开发也可以直接传 localhost。")]
|
||||
public string? Host { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码;本地开发或无独立域名时使用,例如 master。
|
||||
/// 租户编码。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
[Description("租户编码;本地开发或无独立域名时使用,例如 master。")]
|
||||
@@ -34,6 +34,9 @@ public sealed class TenantResolveQueryDto
|
||||
/// <param name="Features">面向前端公开的功能开关。</param>
|
||||
/// <param name="AdminFeatures">面向管理端公开的功能开关。</param>
|
||||
/// <param name="PublicConfig">可公开的租户配置,不包含密钥或内部配置。</param>
|
||||
/// <summary>
|
||||
/// 租户解析Response请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record TenantResolveResponseDto(
|
||||
PublicTenantDto Tenant,
|
||||
PublicTenantBrandingDto Branding,
|
||||
@@ -50,6 +53,9 @@ public sealed record TenantResolveResponseDto(
|
||||
/// <param name="Status">租户状态。</param>
|
||||
/// <param name="Mode">租户模式。</param>
|
||||
/// <param name="Host">当前匹配到的访问域名。</param>
|
||||
/// <summary>
|
||||
/// 公开租户请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record PublicTenantDto(
|
||||
Guid Id,
|
||||
string Slug,
|
||||
@@ -70,6 +76,9 @@ public sealed record PublicTenantDto(
|
||||
/// <param name="ServiceAccountName">公众号或服务号名称。</param>
|
||||
/// <param name="Theme">公开主题配置。</param>
|
||||
/// <param name="PublicAssets">公开资源配置。</param>
|
||||
/// <summary>
|
||||
/// 公开租户品牌请求 DTO。
|
||||
/// </summary>
|
||||
public sealed record PublicTenantBrandingDto(
|
||||
string? BrandName,
|
||||
string? ShortName,
|
||||
|
||||
@@ -5,12 +5,30 @@ using Tiku.Domain.Common;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 保存租户前端配置草稿请求。
|
||||
/// </summary>
|
||||
public sealed class SaveTenantFrontendConfigDraftDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 品牌配置。
|
||||
/// </summary>
|
||||
public JsonElement Branding { get; set; } = JsonDefaults.Object();
|
||||
/// <summary>
|
||||
/// 主题配置。
|
||||
/// </summary>
|
||||
public JsonElement Theme { get; set; } = JsonDefaults.Object();
|
||||
/// <summary>
|
||||
/// 功能配置。
|
||||
/// </summary>
|
||||
public JsonElement Features { get; set; } = JsonDefaults.Object();
|
||||
/// <summary>
|
||||
/// 导航配置。
|
||||
/// </summary>
|
||||
public JsonElement Navigation { get; set; } = JsonDefaults.Array();
|
||||
/// <summary>
|
||||
/// 首页模块配置。
|
||||
/// </summary>
|
||||
public JsonElement HomeModules { get; set; } = JsonDefaults.Array();
|
||||
|
||||
public TenantFrontendConfigDraft ToDraft() => new(
|
||||
@@ -21,8 +39,14 @@ public sealed class SaveTenantFrontendConfigDraftDto
|
||||
HomeModules);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发布租户前端配置请求。
|
||||
/// </summary>
|
||||
public sealed class PublishTenantFrontendConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 期望配置版本。
|
||||
/// </summary>
|
||||
[Range(1, int.MaxValue)]
|
||||
public int ExpectedVersion { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,47 +4,92 @@ using Tiku.Application.Assets;
|
||||
|
||||
namespace Tiku.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 视频Search查询参数。
|
||||
/// </summary>
|
||||
public sealed class VideoSearchQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 关键字。
|
||||
/// </summary>
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 科目 ID。
|
||||
/// </summary>
|
||||
public Guid? SubjectId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 200)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
public VideoSearchQuery ToQuery() => new(Keyword, SubjectId, Limit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 视频Play请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class VideoPlayDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 视频 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid VideoId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
public VideoPlayCommand ToCommand() => new(VideoId, QuestionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 视频Progress请求 DTO。
|
||||
/// </summary>
|
||||
public sealed class VideoProgressDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 视频 ID。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public Guid VideoId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 播放位置,单位为秒。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int PositionSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 时长,单位为秒。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? DurationSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已观看时长,单位为秒。
|
||||
/// </summary>
|
||||
[Range(0, int.MaxValue)]
|
||||
public int? WatchedSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已完成。
|
||||
/// </summary>
|
||||
public bool? IsCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 扩展元数据。
|
||||
/// </summary>
|
||||
public JsonElement Metadata { get; set; } = JsonSerializer.SerializeToElement(new { });
|
||||
|
||||
public VideoProgressCommand ToCommand()
|
||||
@@ -60,13 +105,25 @@ public sealed class VideoProgressDto
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 题目视频查询参数。
|
||||
/// </summary>
|
||||
public sealed class QuestionVideoQueryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 题目 ID。
|
||||
/// </summary>
|
||||
public Guid? QuestionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 题目 ID 列表。
|
||||
/// </summary>
|
||||
[MaxLength(100)]
|
||||
public IReadOnlyCollection<Guid>? QuestionIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 返回数量上限。
|
||||
/// </summary>
|
||||
[Range(1, 500)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("question-collections")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询可用题集")]
|
||||
[ProducesResponseType<CatalogList<QuestionCollectionCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -159,6 +160,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("question-collections/questions")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询题集内题目")]
|
||||
[ProducesResponseType<CatalogList<CollectionQuestionCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
@@ -173,6 +175,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("practice-blueprints")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询练习蓝图")]
|
||||
[ProducesResponseType<CatalogList<PracticeBlueprintCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -186,6 +189,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("question-banks")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询题库列表")]
|
||||
[ProducesResponseType<CatalogList<QuestionBankCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -199,6 +203,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("questions")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询已发布题目")]
|
||||
[EndpointDescription("支持按题库、科目、分类、模块节点、内容入口、内容节点、题集或题目 ID 列表筛选。")]
|
||||
[ProducesResponseType<CatalogList<QuestionCatalogItem>>(StatusCodes.Status200OK)]
|
||||
@@ -213,6 +218,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("questions/{questionId:guid}")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询题目详情")]
|
||||
[ProducesResponseType<QuestionCatalogItem>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -227,6 +233,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("questions/{questionId:guid}/versions")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询题目版本")]
|
||||
[ProducesResponseType<CatalogList<QuestionVersionCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -241,6 +248,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary-units")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
|
||||
[EndpointSummary("查询词汇单元")]
|
||||
[ProducesResponseType<CatalogList<VocabularyUnitCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -254,6 +262,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary-words")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
|
||||
[EndpointSummary("查询词汇单词")]
|
||||
[ProducesResponseType<CatalogList<VocabularyWordCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -267,6 +276,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("handbook-subjects")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("查询知识手册科目")]
|
||||
[ProducesResponseType<CatalogList<HandbookSubjectCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -280,6 +290,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("handbook-chapters")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("查询知识手册章节")]
|
||||
[ProducesResponseType<CatalogList<HandbookChapterCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -293,6 +304,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("handbook-entries")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("查询知识手册条目")]
|
||||
[ProducesResponseType<CatalogList<HandbookEntryCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -345,6 +357,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("video-explanations")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[EndpointSummary("查询视频讲解")]
|
||||
[ProducesResponseType<CatalogList<VideoExplanationCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -358,6 +371,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("question-videos")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[EndpointSummary("查询题目关联视频")]
|
||||
[ProducesResponseType<CatalogList<QuestionVideoCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -371,6 +385,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("banners")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[EndpointSummary("查询首页横幅")]
|
||||
[ProducesResponseType<CatalogList<BannerCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -384,6 +399,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("faqs")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[EndpointSummary("查询常见问题")]
|
||||
[ProducesResponseType<CatalogList<FaqCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -397,6 +413,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("announcements")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[EndpointSummary("查询公告")]
|
||||
[ProducesResponseType<CatalogList<AnnouncementCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -410,6 +427,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("exam-dates")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[EndpointSummary("查询考试日期")]
|
||||
[ProducesResponseType<CatalogList<ExamDateCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -423,6 +441,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("products")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[EndpointSummary("查询可购买产品")]
|
||||
[ProducesResponseType<CatalogList<ProductCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -436,6 +455,7 @@ public sealed class CatalogController(
|
||||
}
|
||||
|
||||
[HttpGet("svip-plans")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[EndpointSummary("查询 SVIP 套餐")]
|
||||
[ProducesResponseType<CatalogList<SvipPlanCatalogItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/commerce")]
|
||||
public sealed class CommerceController(
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = BackendPermissions.TenantCommissionManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.ReferralCommission)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/commission")]
|
||||
public sealed class CommissionController(
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = BackendPermissions.TenantCrmManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Crm)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/crm")]
|
||||
public sealed class CrmController(
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/learning")]
|
||||
public sealed class LearningController(
|
||||
|
||||
@@ -37,8 +37,8 @@ public sealed class PlatformAdminController(
|
||||
[HttpPost("tenants")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("创建平台租户")]
|
||||
[ProducesResponseType<PlatformTenantItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantItem>> CreateTenant(
|
||||
[ProducesResponseType<PlatformTenantProvisioningResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantProvisioningResult>> CreateTenant(
|
||||
CreatePlatformTenantDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -78,55 +78,6 @@ public sealed class PlatformAdminController(
|
||||
return Ok(await platformAdminService.UpsertTenantBillingProfileAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("plans")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("查询平台 SaaS 套餐")]
|
||||
[ProducesResponseType<PlatformPlanList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformPlanList>> Plans(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetPlansAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("plans/{planCode}/modules")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("替换 SaaS 套餐模块权益")]
|
||||
[ProducesResponseType<PlatformPlanModuleEntitlements>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformPlanModuleEntitlements>> ReplacePlanModules(
|
||||
string planCode,
|
||||
ReplacePlatformPlanModulesDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.ReplacePlanModulesAsync(
|
||||
ResolveActor(), request.ToCommand(planCode), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("subscriptions")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("创建或调整租户订阅")]
|
||||
[ProducesResponseType<PlatformTenantSubscriptionItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantSubscriptionItem>> UpsertSubscription(
|
||||
UpsertPlatformSubscriptionDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpsertSubscriptionAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("tenants/{tenantId:guid}/module-overrides/{moduleCode}")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("设置租户模块覆盖")]
|
||||
[ProducesResponseType<PlatformTenantModuleOverrideItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformTenantModuleOverrideItem>> UpsertTenantModuleOverride(
|
||||
Guid tenantId,
|
||||
string moduleCode,
|
||||
UpsertPlatformTenantModuleOverrideDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpsertTenantModuleOverrideAsync(
|
||||
ResolveActor(), request.ToCommand(tenantId, moduleCode), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("domains")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformTenantManage)]
|
||||
[EndpointSummary("查询租户域名状态")]
|
||||
@@ -215,70 +166,70 @@ public sealed class PlatformAdminController(
|
||||
return Ok(await platformAdminService.UpdateAuditAlertStatusAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("dunning-notification-channels")]
|
||||
[HttpGet("saas/dunning/channels")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("查询平台催缴通知渠道")]
|
||||
[ProducesResponseType<PlatformDunningChannelList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDunningChannelList>> DunningChannels(
|
||||
[ProducesResponseType<PlatformBillingDunningChannelList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningChannelList>> BillingDunningChannels(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetDunningChannelsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
return Ok(await platformAdminService.GetBillingDunningChannelsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPut("dunning-notification-channels")]
|
||||
[HttpPut("saas/dunning/channels")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("创建或更新平台催缴通知渠道")]
|
||||
[ProducesResponseType<PlatformDunningChannelItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDunningChannelItem>> UpsertDunningChannel(
|
||||
UpsertPlatformDunningChannelDto request,
|
||||
[ProducesResponseType<PlatformBillingDunningChannelItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningChannelItem>> UpsertBillingDunningChannel(
|
||||
UpsertPlatformBillingDunningChannelDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.UpsertDunningChannelAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
return Ok(await platformAdminService.UpsertBillingDunningChannelAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("dunning-notification-channels/disable")]
|
||||
[HttpPost("saas/dunning/channels/disable")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("禁用平台催缴通知渠道")]
|
||||
[ProducesResponseType<PlatformDunningChannelItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDunningChannelItem>> DisableDunningChannel(
|
||||
DisablePlatformDunningChannelDto request,
|
||||
[ProducesResponseType<PlatformBillingDunningChannelItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningChannelItem>> DisableBillingDunningChannel(
|
||||
DisablePlatformBillingDunningChannelDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.DisableDunningChannelAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
return Ok(await platformAdminService.DisableBillingDunningChannelAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("dunning-notification-events")]
|
||||
[HttpGet("saas/dunning/events")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("查询平台催缴通知事件")]
|
||||
[ProducesResponseType<PlatformDunningEventList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDunningEventList>> DunningEvents(
|
||||
[ProducesResponseType<PlatformBillingDunningEventList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningEventList>> BillingDunningEvents(
|
||||
[FromQuery] PlatformAdminQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetDunningEventsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
return Ok(await platformAdminService.GetBillingDunningEventsAsync(ResolveActor(), query.ToQuery(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("dunning-notification-events/detail")]
|
||||
[HttpGet("saas/dunning/events/detail")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("查询平台催缴通知事件详情")]
|
||||
[ProducesResponseType<PlatformDunningEventItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDunningEventItem>> DunningEventDetail(
|
||||
[ProducesResponseType<PlatformBillingDunningEventItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningEventItem>> BillingDunningEventDetail(
|
||||
[FromQuery] Guid eventId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.GetDunningEventDetailAsync(ResolveActor(), eventId, cancellationToken));
|
||||
return Ok(await platformAdminService.GetBillingDunningEventDetailAsync(ResolveActor(), eventId, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("dunning-notification-events/retry")]
|
||||
[HttpPost("saas/dunning/events/retry")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformBillingNotification)]
|
||||
[EndpointSummary("重新标记平台催缴通知事件待发送")]
|
||||
[ProducesResponseType<PlatformDunningEventItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformDunningEventItem>> RetryDunningEvent(
|
||||
RetryPlatformDunningEventDto request,
|
||||
[ProducesResponseType<PlatformBillingDunningEventItem>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<PlatformBillingDunningEventItem>> RetryBillingDunningEvent(
|
||||
RetryPlatformBillingDunningEventDto request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await platformAdminService.RetryDunningEventAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
return Ok(await platformAdminService.RetryBillingDunningEventAsync(ResolveActor(), request.ToCommand(), cancellationToken));
|
||||
}
|
||||
|
||||
private PlatformAdminActor ResolveActor()
|
||||
|
||||
45
Tiku.Api/Controllers/PlatformBillingCallbackController.cs
Normal file
45
Tiku.Api/Controllers/PlatformBillingCallbackController.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/platform-billing/callbacks")]
|
||||
public sealed class PlatformBillingCallbackController(
|
||||
IPlatformBillingNotificationService notificationService) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpPost("{provider}")]
|
||||
public async Task<IActionResult> Notify(string provider, CancellationToken cancellationToken)
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
|
||||
var rawBody = await reader.ReadToEndAsync(cancellationToken);
|
||||
Request.Body.Position = 0;
|
||||
JsonElement body;
|
||||
try
|
||||
{
|
||||
body = string.IsNullOrWhiteSpace(rawBody)
|
||||
? JsonDocument.Parse("{}").RootElement.Clone()
|
||||
: JsonDocument.Parse(rawBody).RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
body = JsonDocument.Parse("{}").RootElement.Clone();
|
||||
}
|
||||
var normalizedProvider = provider.Trim().ToLowerInvariant().Replace('-', '_');
|
||||
await notificationService.ProcessAsync(new PlatformBillingNotification(
|
||||
normalizedProvider,
|
||||
Request.Headers.ToDictionary(value => value.Key, value => value.Value.ToString(), StringComparer.OrdinalIgnoreCase),
|
||||
rawBody,
|
||||
body), cancellationToken);
|
||||
if (normalizedProvider is "alipay" or "ali_pay")
|
||||
{
|
||||
return Content("success", "text/plain", Encoding.UTF8);
|
||||
}
|
||||
return Ok(new { code = "SUCCESS", message = "成功" });
|
||||
}
|
||||
}
|
||||
98
Tiku.Api/Controllers/PlatformSaasController.cs
Normal file
98
Tiku.Api/Controllers/PlatformSaasController.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/platform-admin/saas")]
|
||||
[Authorize(Policy = TikuPolicies.PlatformBackofficeBootstrap)]
|
||||
public sealed class PlatformSaasController(
|
||||
ISaasCatalogAdminService catalogService,
|
||||
IPlatformBillingAdminService billingService,
|
||||
ICurrentUser currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("catalog")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasCatalogSnapshot> Catalog(CancellationToken cancellationToken) =>
|
||||
catalogService.GetCatalogAsync(Actor(), cancellationToken);
|
||||
|
||||
[HttpPut("features")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasFeature> UpsertFeature(UpsertSaasFeatureDto request, CancellationToken cancellationToken) =>
|
||||
catalogService.UpsertFeatureAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpPut("feature-limits")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasFeatureLimitDefinition> UpsertFeatureLimit(
|
||||
UpsertSaasFeatureLimitDto request,
|
||||
CancellationToken cancellationToken) =>
|
||||
catalogService.UpsertLimitDefinitionAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpPut("offerings")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasOffering> UpsertOffering(UpsertSaasOfferingDto request, CancellationToken cancellationToken) =>
|
||||
catalogService.UpsertOfferingAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpPut("offering-versions")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasOfferingVersionItem> UpsertVersion(UpsertSaasOfferingVersionDto request, CancellationToken cancellationToken) =>
|
||||
catalogService.UpsertDraftVersionAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpPost("offering-versions/{versionId:guid}/publish")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasOfferingVersionItem> PublishVersion(Guid versionId, CancellationToken cancellationToken) =>
|
||||
catalogService.PublishVersionAsync(Actor(), versionId, cancellationToken);
|
||||
|
||||
[HttpPost("offering-versions/{versionId:guid}/clone")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasOfferingVersionItem> CloneVersion(Guid versionId, CancellationToken cancellationToken) =>
|
||||
catalogService.CloneVersionAsync(Actor(), versionId, cancellationToken);
|
||||
|
||||
[HttpPost("offering-versions/{versionId:guid}/retire")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasCatalogManage)]
|
||||
public Task<SaasOfferingVersionItem> RetireVersion(Guid versionId, CancellationToken cancellationToken) =>
|
||||
catalogService.RetireVersionAsync(Actor(), versionId, cancellationToken);
|
||||
|
||||
[HttpGet("orders")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<IReadOnlyCollection<PlatformBillingOrder>> Orders(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
billingService.GetOrdersAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||
|
||||
[HttpGet("payments")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<IReadOnlyCollection<PlatformBillingPayment>> Payments(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
billingService.GetPaymentsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||
|
||||
[HttpGet("refunds")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<IReadOnlyCollection<PlatformBillingRefund>> Refunds(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
billingService.GetRefundsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||
|
||||
[HttpGet("invoices")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<IReadOnlyCollection<PlatformBillingInvoice>> Invoices(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
billingService.GetInvoicesAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||
|
||||
[HttpGet("subscriptions")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<IReadOnlyCollection<TenantSaasSubscription>> Subscriptions(Guid? tenantId, string? status, int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
billingService.GetSubscriptionsAsync(Actor(), new PlatformBillingAdminQuery(tenantId, status, limit), cancellationToken);
|
||||
|
||||
[HttpPost("payments/manual/confirm")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<PlatformBillingPayment> ConfirmManualPayment(ConfirmManualPlatformPaymentDto request, CancellationToken cancellationToken) =>
|
||||
billingService.ConfirmManualPaymentAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpPut("tenant-feature-overrides")]
|
||||
[Authorize(Policy = BackendPermissions.PlatformSaasBillingManage)]
|
||||
public Task<TenantFeatureOverride> UpsertFeatureOverride(UpsertTenantFeatureOverrideDto request, CancellationToken cancellationToken) =>
|
||||
billingService.UpsertFeatureOverrideAsync(Actor(), request.ToCommand(), cancellationToken);
|
||||
|
||||
private SaasCatalogActor Actor() => currentUser.UserId is { } userId
|
||||
? new SaasCatalogActor(userId)
|
||||
: throw new PlatformBillingException("Platform actor was not resolved.", "platform_access_denied");
|
||||
}
|
||||
@@ -8,6 +8,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/points")]
|
||||
public sealed class PointsController(
|
||||
|
||||
@@ -108,6 +108,7 @@ public sealed class ProfileController(
|
||||
}
|
||||
|
||||
[HttpPost("check-in")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[EndpointSummary("每日签到")]
|
||||
[ProducesResponseType<CheckInResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CheckInResult>> CheckIn(CancellationToken cancellationToken)
|
||||
@@ -116,6 +117,7 @@ public sealed class ProfileController(
|
||||
}
|
||||
|
||||
[HttpGet("score-events")]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[EndpointSummary("查询积分流水")]
|
||||
[ProducesResponseType<ProfileScoreEventList>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ProfileScoreEventList>> ScoreEvents(
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/questions/videos")]
|
||||
public sealed class QuestionVideosController(
|
||||
|
||||
@@ -11,6 +11,7 @@ using Tiku.Infrastructure.Persistence;
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.ReferralCommission)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/referral")]
|
||||
public sealed class ReferralController(
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/scoreline")]
|
||||
public sealed class ScorelineController(
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class TaxonomyController(
|
||||
ITaxonomyService taxonomyService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Practice)]
|
||||
[EndpointSummary("查询租户分类节点")]
|
||||
public Task<IReadOnlyCollection<TaxonomyNodeItem>> List(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -22,6 +23,7 @@ public sealed class TaxonomyController(
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)]
|
||||
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
||||
[EndpointSummary("创建租户分类节点")]
|
||||
public Task<TaxonomyNodeItem> Create(
|
||||
|
||||
72
Tiku.Api/Controllers/TenantBillingController.cs
Normal file
72
Tiku.Api/Controllers/TenantBillingController.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/tenant-billing")]
|
||||
[Authorize(Policy = BackendPermissions.TenantBillingManage)]
|
||||
public sealed class TenantBillingController(
|
||||
ITenantBillingService billingService,
|
||||
ICurrentAccessContext accessContext) : ControllerBase
|
||||
{
|
||||
[HttpGet("catalog")]
|
||||
public async Task<TenantBillingCatalog> Catalog(CancellationToken cancellationToken) =>
|
||||
await billingService.GetCatalogAsync(await ActorAsync(cancellationToken), cancellationToken);
|
||||
|
||||
[HttpPost("quotes")]
|
||||
public async Task<PlatformBillingQuoteView> Quote(CreatePlatformBillingQuoteDto request, CancellationToken cancellationToken) =>
|
||||
await billingService.CreateQuoteAsync(await ActorAsync(cancellationToken), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpPost("orders")]
|
||||
public async Task<PlatformBillingOrderView> CreateOrder(CreatePlatformBillingOrderDto request, CancellationToken cancellationToken) =>
|
||||
await billingService.CreateOrderAsync(await ActorAsync(cancellationToken), request.ToCommand(), cancellationToken);
|
||||
|
||||
[HttpPost("orders/{orderNo}/payments")]
|
||||
public async Task<PlatformBillingPaymentView> CreatePayment(string orderNo, CreatePlatformBillingPaymentDto request, CancellationToken cancellationToken) =>
|
||||
await billingService.CreatePaymentAsync(await ActorAsync(cancellationToken), request.ToCommand(orderNo), cancellationToken);
|
||||
|
||||
[HttpGet("orders")]
|
||||
public async Task<IReadOnlyCollection<PlatformBillingOrderView>> Orders(int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
await billingService.GetOrdersAsync(await ActorAsync(cancellationToken), limit, cancellationToken);
|
||||
|
||||
[HttpGet("orders/{orderNo}")]
|
||||
public async Task<PlatformBillingOrderView> Order(string orderNo, CancellationToken cancellationToken) =>
|
||||
await billingService.GetOrderAsync(await ActorAsync(cancellationToken), orderNo, cancellationToken);
|
||||
|
||||
[HttpGet("subscription")]
|
||||
public async Task<TenantSubscriptionView?> Subscription(CancellationToken cancellationToken) =>
|
||||
await billingService.GetSubscriptionAsync(await ActorAsync(cancellationToken), cancellationToken);
|
||||
|
||||
[HttpPost("subscription/change")]
|
||||
public async Task<PlatformBillingOrderView> Change(ChangeTenantSubscriptionDto request, CancellationToken cancellationToken) =>
|
||||
await billingService.ChangeSubscriptionAsync(await ActorAsync(cancellationToken), request.ToCommand(), request.IdempotencyKey, cancellationToken);
|
||||
|
||||
[HttpPost("subscription/renew")]
|
||||
public async Task<PlatformBillingOrderView> Renew(IdempotentTenantBillingDto request, CancellationToken cancellationToken) =>
|
||||
await billingService.RenewSubscriptionAsync(await ActorAsync(cancellationToken), request.IdempotencyKey, cancellationToken);
|
||||
|
||||
[HttpPost("subscription/cancel")]
|
||||
public async Task<TenantSubscriptionView> Cancel(CancellationToken cancellationToken) =>
|
||||
await billingService.CancelSubscriptionAsync(await ActorAsync(cancellationToken), cancellationToken);
|
||||
|
||||
[HttpGet("usage")]
|
||||
public async Task<IReadOnlyCollection<FeatureQuotaSnapshot>> Usage(CancellationToken cancellationToken) =>
|
||||
await billingService.GetUsageAsync(await ActorAsync(cancellationToken), cancellationToken);
|
||||
|
||||
[HttpGet("invoices")]
|
||||
public async Task<IReadOnlyCollection<PlatformBillingInvoice>> Invoices(int limit = 100, CancellationToken cancellationToken = default) =>
|
||||
await billingService.GetInvoicesAsync(await ActorAsync(cancellationToken), limit, cancellationToken);
|
||||
|
||||
private async Task<TenantBillingActor> ActorAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var access = await accessContext.GetAsync(cancellationToken);
|
||||
return access.UserId is { } userId && access.TenantId is { } tenantId && access.IsCurrentTenantMember
|
||||
? new TenantBillingActor(userId, tenantId)
|
||||
: throw new PlatformBillingException("Tenant billing actor was not resolved.", "tenant_access_denied");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = BackendPermissions.TenantCommerceOperate)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.StudentStore)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant-commerce")]
|
||||
public sealed class TenantCommerceController(
|
||||
|
||||
@@ -235,6 +235,20 @@ public sealed class TenantContentController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpDelete("assets/{assetId:guid}")]
|
||||
[EndpointSummary("归档内容资产")]
|
||||
[ProducesResponseType<ContentManagementResult<ContentAssetManagementItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<ContentManagementResult<ContentAssetManagementItem>>> ArchiveAsset(
|
||||
Guid assetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await assetManagementService.ArchiveAssetAsync(
|
||||
ResolveActor(),
|
||||
assetId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("assets/sign-download")]
|
||||
[EndpointSummary("签发管理侧资产下载地址")]
|
||||
[ProducesResponseType<AssetManagementSignedAccessResult>(StatusCodes.Status200OK)]
|
||||
|
||||
@@ -12,7 +12,6 @@ using Tiku.Domain.Content;
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/tenant-content")]
|
||||
public sealed class TenantContentDirectController(
|
||||
@@ -22,7 +21,9 @@ public sealed class TenantContentDirectController(
|
||||
ITenantContext currentTenant) : ControllerBase
|
||||
{
|
||||
[HttpPost("questions")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)]
|
||||
[EndpointSummary("创建题目及首个版本")]
|
||||
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> CreateQuestion(
|
||||
@@ -33,7 +34,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPatch("questions")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantContentManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.PrivateQuestionBank)]
|
||||
[EndpointSummary("更新题目并可选择创建新版本")]
|
||||
[ProducesResponseType<ContentManagementResult<QuestionManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<QuestionManagementItem>>> UpdateQuestion(
|
||||
@@ -44,6 +47,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary-units")]
|
||||
[Authorize(Policy = BackendPermissions.TenantVocabularyManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
|
||||
[EndpointSummary("查询管理侧词汇单元")]
|
||||
[ProducesResponseType<CatalogList<VocabularyUnit>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<VocabularyUnit>>> GetVocabularyUnits(
|
||||
@@ -54,6 +59,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("vocabulary-units")]
|
||||
[Authorize(Policy = BackendPermissions.TenantVocabularyManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
|
||||
[EndpointSummary("新增或更新词汇单元")]
|
||||
[ProducesResponseType<ContentManagementResult<VocabularyUnit>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<VocabularyUnit>>> UpsertVocabularyUnit(
|
||||
@@ -64,7 +71,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("vocabulary-words")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantVocabularyManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
|
||||
[EndpointSummary("查询管理侧词汇")]
|
||||
[ProducesResponseType<CatalogList<VocabularyWord>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<VocabularyWord>>> GetVocabularyWords(
|
||||
@@ -75,7 +84,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("vocabulary-words")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantVocabularyManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Vocabulary)]
|
||||
[EndpointSummary("新增或更新词汇")]
|
||||
[ProducesResponseType<ContentManagementResult<VocabularyWord>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<VocabularyWord>>> UpsertVocabularyWord(
|
||||
@@ -86,6 +97,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("handbook-subjects")]
|
||||
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("查询管理侧知识手册科目")]
|
||||
[ProducesResponseType<CatalogList<HandbookSubject>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<HandbookSubject>>> GetHandbookSubjects(
|
||||
@@ -96,6 +109,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("handbook-subjects")]
|
||||
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("新增或更新知识手册科目")]
|
||||
[ProducesResponseType<ContentManagementResult<HandbookSubject>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<HandbookSubject>>> UpsertHandbookSubject(
|
||||
@@ -106,7 +121,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("handbook-chapters")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("查询管理侧知识手册章节")]
|
||||
[ProducesResponseType<CatalogList<HandbookChapter>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<HandbookChapter>>> GetHandbookChapters(
|
||||
@@ -117,7 +134,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("handbook-chapters")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("新增或更新知识手册章节")]
|
||||
[ProducesResponseType<ContentManagementResult<HandbookChapter>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<HandbookChapter>>> UpsertHandbookChapter(
|
||||
@@ -128,7 +147,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("handbook-entries")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("查询管理侧知识手册条目")]
|
||||
[ProducesResponseType<CatalogList<HandbookEntry>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<HandbookEntry>>> GetHandbookEntries(
|
||||
@@ -139,7 +160,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("handbook-entries")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantHandbookManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Handbook)]
|
||||
[EndpointSummary("新增或更新知识手册条目")]
|
||||
[ProducesResponseType<ContentManagementResult<HandbookEntry>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<HandbookEntry>>> UpsertHandbookEntry(
|
||||
@@ -150,6 +173,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("scoreline/schools")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("查询管理侧分数线院校")]
|
||||
[ProducesResponseType<CatalogList<School>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<School>>> GetSchools(
|
||||
@@ -160,6 +185,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("scoreline/schools")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("新增或更新分数线院校")]
|
||||
[ProducesResponseType<ContentManagementResult<School>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<School>>> UpsertSchool(
|
||||
@@ -170,6 +197,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("scoreline/majors")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("查询管理侧分数线专业")]
|
||||
[ProducesResponseType<CatalogList<Major>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<Major>>> GetMajors(
|
||||
@@ -180,6 +209,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("scoreline/majors")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("新增或更新分数线专业")]
|
||||
[ProducesResponseType<ContentManagementResult<Major>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<Major>>> UpsertMajor(
|
||||
@@ -190,6 +221,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("scoreline/fields")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("查询管理侧分数线字段")]
|
||||
[ProducesResponseType<CatalogList<ScorelineField>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<ScorelineField>>> GetScorelineFields(
|
||||
@@ -200,6 +233,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("scoreline/fields")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("新增或更新动态分数线字段")]
|
||||
[ProducesResponseType<ContentManagementResult<ScorelineField>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<ScorelineField>>> UpsertScorelineField(
|
||||
@@ -210,6 +245,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("scoreline/records")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("查询管理侧分数线记录")]
|
||||
[ProducesResponseType<CatalogList<ScorelineRecord>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<ScorelineRecord>>> GetScorelineRecords(
|
||||
@@ -220,6 +257,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("scoreline/records")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("新增或更新分数线记录")]
|
||||
[ProducesResponseType<ContentManagementResult<ScorelineRecord>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<ScorelineRecord>>> UpsertScorelineRecord(
|
||||
@@ -230,6 +269,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("scoreline/years")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("查询分数线年份")]
|
||||
[ProducesResponseType<CatalogList<int>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<int>>> GetScorelineYears(
|
||||
@@ -240,6 +281,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("scoreline/trend")]
|
||||
[Authorize(Policy = BackendPermissions.TenantScorelineManage)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Scoreline)]
|
||||
[EndpointSummary("查询分数线趋势摘要")]
|
||||
[ProducesResponseType<CatalogList<ScorelineTrendItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<ScorelineTrendItem>>> GetScorelineTrend(
|
||||
@@ -250,7 +293,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("videos")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantVideoManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[EndpointSummary("查询租户视频解析")]
|
||||
[ProducesResponseType<CatalogList<VideoManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<VideoManagementItem>>> GetVideos(
|
||||
@@ -261,7 +306,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("videos")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantVideoManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[EndpointSummary("新增或更新视频解析")]
|
||||
[ProducesResponseType<ContentManagementResult<VideoManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<VideoManagementItem>>> UpsertVideo(
|
||||
@@ -272,7 +319,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("question-videos")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantVideoManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[EndpointSummary("绑定题目与解析视频")]
|
||||
[ProducesResponseType<ContentManagementResult<QuestionVideoManagementItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<QuestionVideoManagementItem>>> BindQuestionVideo(
|
||||
@@ -283,7 +332,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("operations/{kind}")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantSiteContentManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[EndpointSummary("查询运营内容")]
|
||||
[ProducesResponseType<CatalogList<OperationContentItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<OperationContentItem>>> GetOperationContent(
|
||||
@@ -295,7 +346,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPut("operations/{kind}")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantSiteContentManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.SiteContent)]
|
||||
[EndpointSummary("新增或更新运营内容")]
|
||||
[ProducesResponseType<ContentManagementResult<OperationContentItem>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentManagementResult<OperationContentItem>>> UpsertOperationContent(
|
||||
@@ -307,7 +360,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("imports/preview/{importType}")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeatureFromRoute("importType")]
|
||||
[EndpointSummary("预览内容导入数据")]
|
||||
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<SimpleImportResult>> PreviewImport(
|
||||
@@ -319,7 +374,9 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("imports/{importType}")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[Tiku.Api.Security.RequireSaasFeatureFromRoute("importType")]
|
||||
[EndpointSummary("执行或排队内容导入")]
|
||||
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status202Accepted)]
|
||||
@@ -359,7 +416,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("imports/detail")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[EndpointSummary("查询内容导入任务详情")]
|
||||
[ProducesResponseType<ContentImportJobDetail>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ContentImportJobDetail>> GetImportDetail(
|
||||
@@ -370,7 +428,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("imports/issues")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[EndpointSummary("查询内容导入问题明细")]
|
||||
[ProducesResponseType<CatalogList<ContentImportIssueModel>>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<CatalogList<ContentImportIssueModel>>> GetImportIssues(
|
||||
@@ -381,7 +440,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpPost("imports/post-check")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[EndpointSummary("执行内容导入后完整性检查")]
|
||||
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ImportPostCheckResult>> RunImportPostCheck(
|
||||
@@ -392,7 +452,8 @@ public sealed class TenantContentDirectController(
|
||||
}
|
||||
|
||||
[HttpGet("imports/post-check")]
|
||||
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
|
||||
[Authorize(Policy = BackendPermissions.TenantJobManage)]
|
||||
[Authorize(Policy = TikuPolicies.TenantAllDataScope)]
|
||||
[EndpointSummary("查询内容导入后检查状态")]
|
||||
[ProducesResponseType<ImportPostCheckResult>(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<ImportPostCheckResult>> GetImportPostCheck(
|
||||
|
||||
25
Tiku.Api/Controllers/TenantOnboardingController.cs
Normal file
25
Tiku.Api/Controllers/TenantOnboardingController.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/tenant-onboarding")]
|
||||
[Authorize(Policy = BackendPermissions.TenantSettingsManage)]
|
||||
public sealed class TenantOnboardingController(
|
||||
ITenantOnboardingService onboardingService,
|
||||
ICurrentAccessContext accessContext) : ControllerBase
|
||||
{
|
||||
[HttpGet("status")]
|
||||
public async Task<TenantOnboardingStatus> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
var access = await accessContext.GetAsync(cancellationToken);
|
||||
if (access.TenantId is not { } tenantId || !access.IsCurrentTenantMember)
|
||||
{
|
||||
throw new TenantExternalProviderException("Tenant onboarding access is denied.", "tenant_access_denied");
|
||||
}
|
||||
return await onboardingService.GetStatusAsync(tenantId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ namespace Tiku.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
|
||||
[Tiku.Api.Security.RequireSaasFeature(SaasFeatureCatalog.Video)]
|
||||
[Produces("application/json")]
|
||||
[Route("api/videos")]
|
||||
public sealed class VideosController(
|
||||
|
||||
@@ -8,6 +8,7 @@ using Tiku.Application.Content;
|
||||
using Tiku.Application.Growth;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Infrastructure.Content;
|
||||
@@ -56,6 +57,18 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is FeatureAccessException featureAccessException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
featureAccessException.Message,
|
||||
featureAccessException.Code == "feature_quota_exhausted"
|
||||
? StatusCodes.Status409Conflict
|
||||
: StatusCodes.Status403Forbidden,
|
||||
featureAccessException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is TenantContextConflictException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
@@ -262,6 +275,16 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is PlatformBillingException platformBillingException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
context,
|
||||
platformBillingException.Message,
|
||||
PlatformBillingStatusCode(platformBillingException.Code),
|
||||
platformBillingException.Code);
|
||||
return;
|
||||
}
|
||||
|
||||
if (exception is CommerceException commerceException)
|
||||
{
|
||||
await WriteProblemAsync(
|
||||
@@ -518,6 +541,22 @@ public sealed class ExceptionHandlingMiddleware(
|
||||
};
|
||||
}
|
||||
|
||||
private static int PlatformBillingStatusCode(string code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
"tenant_not_found" => StatusCodes.Status404NotFound,
|
||||
"platform_billing_public_url_missing" or "payment_provider_not_configured" or
|
||||
"payment_secret_not_configured" => StatusCodes.Status503ServiceUnavailable,
|
||||
"saas_offering_version_immutable" or "saas_offering_version_status_invalid" or
|
||||
"platform_billing_quote_expired" or "platform_billing_order_status_invalid" or
|
||||
"platform_billing_payment_status_invalid" or "platform_billing_payment_amount_mismatch" =>
|
||||
StatusCodes.Status409Conflict,
|
||||
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
|
||||
_ => StatusCodes.Status400BadRequest
|
||||
};
|
||||
}
|
||||
|
||||
private static int PointStatusCode(string code)
|
||||
{
|
||||
return code switch
|
||||
|
||||
@@ -76,7 +76,7 @@ internal sealed class CurrentAccessAuthorizationHandler(ICurrentAccessContext ac
|
||||
|
||||
internal sealed class TenantPermissionAuthorizationHandler(
|
||||
ICurrentAccessContext accessContext,
|
||||
ICapabilityAccessEvaluator capabilityAccessEvaluator,
|
||||
IFeatureAccessService featureAccessService,
|
||||
IHttpContextAccessor httpContextAccessor) :
|
||||
AuthorizationHandler<TenantPermissionRequirement>
|
||||
{
|
||||
@@ -90,24 +90,18 @@ internal sealed class TenantPermissionAuthorizationHandler(
|
||||
}
|
||||
|
||||
var access = await accessContext.GetAsync();
|
||||
var moduleCode = ResolveModuleCode(requirement.PermissionCode);
|
||||
var operation = IsSafeMethod(httpContextAccessor.HttpContext?.Request.Method)
|
||||
? CapabilityOperation.Read
|
||||
: CapabilityOperation.Write;
|
||||
? FeatureAccessOperation.Read
|
||||
: FeatureAccessOperation.Write;
|
||||
if (access.TenantId is { } tenantId &&
|
||||
access.HasTenantPermission(requirement.PermissionCode) &&
|
||||
await capabilityAccessEvaluator.IsAllowedAsync(tenantId, moduleCode, operation))
|
||||
(await featureAccessService.FilterPermissionCodesAsync(
|
||||
tenantId, [requirement.PermissionCode], operation)).Contains(requirement.PermissionCode))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveModuleCode(string permissionCode)
|
||||
{
|
||||
var parts = permissionCode.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permissionCode.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool IsSafeMethod(string? method) =>
|
||||
method is not null &&
|
||||
(HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method));
|
||||
@@ -197,7 +191,7 @@ public static class AccessAuthorizationServiceCollectionExtensions
|
||||
public static IServiceCollection AddTikuRbacAuthorization(this IServiceCollection services)
|
||||
{
|
||||
services.AddHttpContextAccessor();
|
||||
services.TryAddScoped<ICapabilityAccessEvaluator, CompatibilityCapabilityAccessEvaluator>();
|
||||
services.TryAddScoped<IFeatureAccessService, CompatibilityFeatureAccessService>();
|
||||
services.AddScoped<IAuthorizationHandler, CurrentAccessAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, CurrentPlatformAccessAuthorizationHandler>();
|
||||
services.AddScoped<IAuthorizationHandler, TenantPermissionAuthorizationHandler>();
|
||||
@@ -241,6 +235,13 @@ public static class AccessAuthorizationServiceCollectionExtensions
|
||||
new CurrentTenantMemberRequirement(),
|
||||
new TenantPermissionRequirement(BackendPermissions.TenantContentManage),
|
||||
new AllDataScopeRequirement()));
|
||||
options.AddPolicy(
|
||||
TikuPolicies.TenantAllDataScope,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(
|
||||
new CurrentTenantMemberRequirement(),
|
||||
new AllDataScopeRequirement()));
|
||||
options.AddPolicy(
|
||||
TikuPolicies.TenantCommerceOperateAllScope,
|
||||
policy => policy
|
||||
@@ -275,17 +276,21 @@ public static class AccessAuthorizationServiceCollectionExtensions
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CompatibilityCapabilityAccessEvaluator : ICapabilityAccessEvaluator
|
||||
internal sealed class CompatibilityFeatureAccessService : IFeatureAccessService
|
||||
{
|
||||
public Task<bool> IsAllowedAsync(
|
||||
Guid tenantId,
|
||||
string moduleCode,
|
||||
CapabilityOperation operation,
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
public Task<FeatureAccessDecision> EvaluateAsync(Guid tenantId, string featureCode, FeatureAccessOperation operation, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new FeatureAccessDecision(true, null, featureCode, operation));
|
||||
|
||||
public Task<IReadOnlySet<string>> GetEnabledModulesAsync(
|
||||
Guid tenantId,
|
||||
CapabilityOperation operation = CapabilityOperation.Read,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.Ordinal));
|
||||
public Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(Guid tenantId, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(SaasFeatureCatalog.All, StringComparer.Ordinal));
|
||||
|
||||
public Task<IReadOnlySet<string>> FilterPermissionCodesAsync(Guid tenantId, IEnumerable<string> permissionCodes, FeatureAccessOperation operation = FeatureAccessOperation.Read, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlySet<string>>(permissionCodes.ToHashSet(StringComparer.Ordinal));
|
||||
|
||||
public Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyCollection<FeatureQuotaSnapshot>>([]);
|
||||
|
||||
public Task<bool> TryConsumeQuotaAsync(Guid tenantId, string metricCode, long amount, CancellationToken cancellationToken = default) => Task.FromResult(true);
|
||||
|
||||
public Task ReleaseQuotaAsync(Guid tenantId, string metricCode, long amount, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace Tiku.Api.Security;
|
||||
public sealed record EndpointAuthorizationMetadata(
|
||||
string Realm,
|
||||
string? Module,
|
||||
IReadOnlyList<string> RequiredFeatures,
|
||||
string? Permission,
|
||||
CapabilityOperation Operation,
|
||||
bool RequiresAllDataScope,
|
||||
@@ -43,7 +44,20 @@ internal sealed class EndpointAuthorizationMetadataConvention : IApplicationMode
|
||||
policies.Any(policy => policy.StartsWith("tenant", StringComparison.Ordinal))
|
||||
? "tenant"
|
||||
: "authenticated";
|
||||
var module = permission is null ? null : ResolveModule(permission);
|
||||
var module = permission is null ? null : PermissionModuleCatalog.ResolvePermissionModuleCode(permission);
|
||||
var requiredFeatures = controller.Attributes.OfType<RequireSaasFeatureAttribute>()
|
||||
.Concat(action.Attributes.OfType<RequireSaasFeatureAttribute>())
|
||||
.Select(attribute => attribute.FeatureCode)
|
||||
.Concat(module is not null &&
|
||||
PermissionModuleCatalog.RequiredFeatures.TryGetValue(module, out var moduleFeature) &&
|
||||
moduleFeature is not null
|
||||
? [moduleFeature]
|
||||
: [])
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.Concat(action.Attributes.OfType<RequireSaasFeatureFromRouteAttribute>()
|
||||
.Select(attribute => $"route:{attribute.RouteValueName}"))
|
||||
.ToArray();
|
||||
var httpMethods = action.Attributes.OfType<HttpMethodAttribute>()
|
||||
.SelectMany(attribute => attribute.HttpMethods)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -55,9 +69,11 @@ internal sealed class EndpointAuthorizationMetadataConvention : IApplicationMode
|
||||
var metadata = new EndpointAuthorizationMetadata(
|
||||
realm,
|
||||
module,
|
||||
requiredFeatures,
|
||||
permission,
|
||||
operation,
|
||||
policies.Contains(TikuPolicies.TenantContentManageAllScope, StringComparer.Ordinal) ||
|
||||
policies.Contains(TikuPolicies.TenantAllDataScope, StringComparer.Ordinal) ||
|
||||
policies.Contains(TikuPolicies.TenantCommerceOperateAllScope, StringComparer.Ordinal),
|
||||
$"{string.Join(',', httpMethods.Order(StringComparer.Ordinal))}:{route}");
|
||||
foreach (var selector in action.Selectors)
|
||||
@@ -68,12 +84,6 @@ internal sealed class EndpointAuthorizationMetadataConvention : IApplicationMode
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveModule(string permission)
|
||||
{
|
||||
var parts = permission.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permission.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool IsSafeMethod(string method) =>
|
||||
HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsOptions(method);
|
||||
}
|
||||
|
||||
113
Tiku.Api/Security/SaasFeatureEndpointMetadata.cs
Normal file
113
Tiku.Api/Security/SaasFeatureEndpointMetadata.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Security;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
|
||||
public sealed class RequireSaasFeatureAttribute(string featureCode) : Attribute
|
||||
{
|
||||
public string FeatureCode { get; } = featureCode;
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
|
||||
public sealed class RequireSaasFeatureFromRouteAttribute(string routeValueName) : Attribute
|
||||
{
|
||||
public string RouteValueName { get; } = routeValueName;
|
||||
}
|
||||
|
||||
public sealed class SaasFeatureAccessMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(
|
||||
HttpContext context,
|
||||
ITenantContext tenantContext,
|
||||
IFeatureAccessService featureAccessService)
|
||||
{
|
||||
var requirements = context.GetEndpoint()?.Metadata.GetOrderedMetadata<RequireSaasFeatureAttribute>();
|
||||
var routeRequirements = context.GetEndpoint()?.Metadata.GetOrderedMetadata<RequireSaasFeatureFromRouteAttribute>();
|
||||
if ((requirements is null || requirements.Count == 0) &&
|
||||
(routeRequirements is null || routeRequirements.Count == 0))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenantContext.IsResolved || tenantContext.IsSystem || tenantContext.TenantId is not { } tenantId)
|
||||
{
|
||||
// Some platform-hosted public entry points resolve their tenant from a
|
||||
// signed code inside the application service. They must keep their
|
||||
// existing 404 semantics when no request tenant has been established.
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var operation = HttpMethods.IsGet(context.Request.Method) ||
|
||||
HttpMethods.IsHead(context.Request.Method) ||
|
||||
HttpMethods.IsOptions(context.Request.Method)
|
||||
? FeatureAccessOperation.Read
|
||||
: FeatureAccessOperation.Write;
|
||||
foreach (var requirement in (requirements ?? []).DistinctBy(value => value.FeatureCode, StringComparer.Ordinal))
|
||||
{
|
||||
var decision = await featureAccessService.EvaluateAsync(
|
||||
tenantId,
|
||||
requirement.FeatureCode,
|
||||
operation,
|
||||
context.RequestAborted);
|
||||
if (!decision.Allowed)
|
||||
{
|
||||
await WriteForbiddenAsync(
|
||||
context,
|
||||
decision.DenialCode ?? "feature_not_available",
|
||||
$"The SaaS feature '{requirement.FeatureCode}' is not available.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
foreach (var requirement in routeRequirements ?? [])
|
||||
{
|
||||
var routeValue = context.Request.RouteValues[requirement.RouteValueName]?.ToString();
|
||||
var featureCode = ResolveRouteFeature(routeValue);
|
||||
if (featureCode is null)
|
||||
{
|
||||
await WriteForbiddenAsync(context, "feature_route_value_invalid", "The requested content module is not available.");
|
||||
return;
|
||||
}
|
||||
var decision = await featureAccessService.EvaluateAsync(
|
||||
tenantId,
|
||||
featureCode,
|
||||
operation,
|
||||
context.RequestAborted);
|
||||
if (!decision.Allowed)
|
||||
{
|
||||
await WriteForbiddenAsync(
|
||||
context,
|
||||
decision.DenialCode ?? "feature_not_available",
|
||||
$"The SaaS feature '{featureCode}' is not available.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
|
||||
private static Task WriteForbiddenAsync(HttpContext context, string code, string title)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return context.Response.WriteAsJsonAsync(new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status403Forbidden,
|
||||
Title = title,
|
||||
Instance = context.Request.Path,
|
||||
Extensions =
|
||||
{
|
||||
["code"] = code,
|
||||
["traceId"] = context.TraceIdentifier
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
internal static string? ResolveRouteFeature(string? routeValue)
|
||||
{
|
||||
return SaasFeatureCatalog.ResolveContentImportFeature(routeValue);
|
||||
}
|
||||
}
|
||||
@@ -86,9 +86,9 @@
|
||||
['P-08', 'PlatformAdminBillingController_confirmPayment', 'POST', '/api/platform-admin/invoices/payments/manual-confirm'],
|
||||
['P-08', 'PlatformAdminBillingController_processOverdue', 'POST', '/api/platform-admin/invoices/process-overdue'],
|
||||
['P-08', 'PlatformAdminBillingController_reminders', 'GET', '/api/platform-admin/invoices/reminders'],
|
||||
['P-08', 'PlatformAdminDunningChannelsController_channels', 'GET', '/api/platform-admin/dunning-notification-channels'],
|
||||
['P-08', 'PlatformAdminDunningChannelsController_upsertChannel', 'PUT', '/api/platform-admin/dunning-notification-channels'],
|
||||
['P-08', 'PlatformAdminDunningEventsController_events', 'GET', '/api/platform-admin/dunning-notification-events'],
|
||||
['P-08', 'PlatformAdminDunningChannelsController_channels', 'GET', '/api/platform-admin/saas/dunning/channels'],
|
||||
['P-08', 'PlatformAdminDunningChannelsController_upsertChannel', 'PUT', '/api/platform-admin/saas/dunning/channels'],
|
||||
['P-08', 'PlatformAdminDunningEventsController_events', 'GET', '/api/platform-admin/saas/dunning/events'],
|
||||
['P-09', 'PlatformAdminQuestionBanksController_banks', 'GET', '/api/platform-admin/question-banks'],
|
||||
['P-09', 'PlatformAdminQuestionBanksController_grants', 'GET', '/api/platform-admin/question-bank-grants'],
|
||||
['P-09', 'PlatformAdminQuestionBanksController_upsertGrant', 'PUT', '/api/platform-admin/question-bank-grants'],
|
||||
|
||||
@@ -25,6 +25,11 @@ public interface IAssetManagementService
|
||||
AssetUploadConfirmCommand command,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ContentManagementResult<ContentAssetManagementItem>> ArchiveAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
Guid assetId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AssetManagementSignedAccessResult> SignDownloadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
|
||||
@@ -36,14 +36,16 @@ public sealed record BackofficeBootstrap(
|
||||
|
||||
public sealed record BackofficeUiBootstrap(
|
||||
IReadOnlyCollection<string> PermissionCodes,
|
||||
IReadOnlyCollection<BackofficeMenuItem> Menus);
|
||||
IReadOnlyCollection<BackofficeMenuItem> Menus,
|
||||
IReadOnlyCollection<string> EnabledFeatures,
|
||||
IReadOnlyCollection<FeatureQuotaSnapshot> Quotas);
|
||||
|
||||
public sealed record BackofficePermissionItem(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
BackendPermissionArea Area,
|
||||
string Module,
|
||||
string PermissionModuleCode,
|
||||
string? Description,
|
||||
int SortOrder);
|
||||
|
||||
|
||||
@@ -58,12 +58,12 @@ public sealed record PlatformTenantDomainItem(
|
||||
public sealed record PlatformTenantSubscriptionItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
string PlanCode,
|
||||
TenantSubscriptionStatus Status,
|
||||
DateTimeOffset? StartsAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? BillingCycle,
|
||||
int AmountCents,
|
||||
Guid BaseOfferingVersionId,
|
||||
TenantSaasSubscriptionStatus Status,
|
||||
DateTimeOffset StartsAt,
|
||||
DateTimeOffset CurrentPeriodStart,
|
||||
DateTimeOffset CurrentPeriodEnd,
|
||||
bool CancelAtPeriodEnd,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record TenantBillingProfileItem(
|
||||
@@ -75,7 +75,7 @@ public sealed record TenantBillingProfileItem(
|
||||
string? ContactEmail,
|
||||
string? BillingAddress,
|
||||
string? InvoiceTitle,
|
||||
TenantInvoiceTitleType? InvoiceType,
|
||||
TenantBillingInvoiceTitleType? InvoiceType,
|
||||
string? BankName,
|
||||
string? BankAccountMasked,
|
||||
JsonElement Metadata);
|
||||
@@ -86,7 +86,17 @@ public sealed record CreatePlatformTenantCommand(
|
||||
string? LegalName,
|
||||
TenantStatus Status,
|
||||
BillingStatus BillingStatus,
|
||||
JsonElement Metadata);
|
||||
JsonElement Metadata,
|
||||
string? OwnerEmail,
|
||||
string? OwnerPhone,
|
||||
string OwnerName,
|
||||
string TemporaryPassword);
|
||||
|
||||
public sealed record PlatformTenantProvisioningResult(
|
||||
PlatformTenantItem Tenant,
|
||||
Guid OwnerUserId,
|
||||
string OwnerIdentifier,
|
||||
bool MustChangePassword);
|
||||
|
||||
public sealed record UpdatePlatformTenantStatusCommand(
|
||||
Guid TenantId,
|
||||
@@ -103,45 +113,11 @@ public sealed record UpsertPlatformTenantBillingProfileCommand(
|
||||
string? ContactEmail,
|
||||
string? BillingAddress,
|
||||
string? InvoiceTitle,
|
||||
TenantInvoiceTitleType? InvoiceType,
|
||||
TenantBillingInvoiceTitleType? InvoiceType,
|
||||
string? BankName,
|
||||
string? BankAccountMasked,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record PlatformPlanList(IReadOnlyCollection<PlatformSaasPlan> Items);
|
||||
|
||||
public sealed record PlatformPlanModuleEntitlements(
|
||||
string PlanCode,
|
||||
IReadOnlyCollection<string> ModuleCodes);
|
||||
|
||||
public sealed record ReplacePlatformPlanModulesCommand(
|
||||
string PlanCode,
|
||||
IReadOnlyCollection<string> ModuleCodes);
|
||||
|
||||
public sealed record PlatformTenantModuleOverrideItem(
|
||||
Guid TenantId,
|
||||
string ModuleCode,
|
||||
TenantModuleOverrideMode Mode,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? Reason);
|
||||
|
||||
public sealed record UpsertPlatformTenantModuleOverrideCommand(
|
||||
Guid TenantId,
|
||||
string ModuleCode,
|
||||
TenantModuleOverrideMode Mode,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string Reason);
|
||||
|
||||
public sealed record UpsertPlatformSubscriptionCommand(
|
||||
Guid TenantId,
|
||||
string PlanCode,
|
||||
TenantSubscriptionStatus Status,
|
||||
DateTimeOffset? StartsAt,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
string? BillingCycle,
|
||||
int AmountCents,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record PlatformDomainList(IReadOnlyCollection<PlatformTenantDomainItem> Items);
|
||||
|
||||
public sealed record PlatformDomainRecheckResult(Guid DomainId, TenantDomainStatus Status, DateTimeOffset LastCheckedAt);
|
||||
@@ -189,15 +165,15 @@ public sealed record UpdatePlatformAuditAlertStatusCommand(
|
||||
PlatformAuditAlertStatus Status,
|
||||
string? ResolutionNote);
|
||||
|
||||
public sealed record PlatformDunningChannelList(IReadOnlyCollection<PlatformDunningChannelItem> Items);
|
||||
public sealed record PlatformBillingDunningChannelList(IReadOnlyCollection<PlatformBillingDunningChannelItem> Items);
|
||||
|
||||
public sealed record PlatformDunningChannelItem(
|
||||
public sealed record PlatformBillingDunningChannelItem(
|
||||
Guid Id,
|
||||
string ChannelCode,
|
||||
string Name,
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
PlatformDunningProvider Provider,
|
||||
PlatformBillingDunningProvider Provider,
|
||||
string WebhookUrlMasked,
|
||||
string? SecretRef,
|
||||
IReadOnlyCollection<string> ReminderTypes,
|
||||
@@ -209,13 +185,13 @@ public sealed record PlatformDunningChannelItem(
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record UpsertPlatformDunningChannelCommand(
|
||||
public sealed record UpsertPlatformBillingDunningChannelCommand(
|
||||
Guid? ChannelId,
|
||||
string ChannelCode,
|
||||
string Name,
|
||||
string? Description,
|
||||
bool Enabled,
|
||||
PlatformDunningProvider Provider,
|
||||
PlatformBillingDunningProvider Provider,
|
||||
string WebhookUrl,
|
||||
string? SecretRef,
|
||||
IReadOnlyCollection<string> ReminderTypes,
|
||||
@@ -225,18 +201,18 @@ public sealed record UpsertPlatformDunningChannelCommand(
|
||||
int TimeoutSeconds,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record DisablePlatformDunningChannelCommand(Guid ChannelId, string? Reason);
|
||||
public sealed record DisablePlatformBillingDunningChannelCommand(Guid ChannelId, string? Reason);
|
||||
|
||||
public sealed record PlatformDunningEventList(IReadOnlyCollection<PlatformDunningEventItem> Items);
|
||||
public sealed record PlatformBillingDunningEventList(IReadOnlyCollection<PlatformBillingDunningEventItem> Items);
|
||||
|
||||
public sealed record PlatformDunningEventItem(
|
||||
public sealed record PlatformBillingDunningEventItem(
|
||||
Guid Id,
|
||||
Guid TenantId,
|
||||
Guid ChannelId,
|
||||
Guid ReminderId,
|
||||
Guid InvoiceId,
|
||||
PlatformDunningProvider Provider,
|
||||
PlatformDunningNotificationStatus Status,
|
||||
PlatformBillingDunningProvider Provider,
|
||||
PlatformBillingDunningNotificationStatus Status,
|
||||
int Attempts,
|
||||
DateTimeOffset ScheduledAt,
|
||||
DateTimeOffset? NextAttemptAt,
|
||||
@@ -250,20 +226,16 @@ public sealed record PlatformDunningEventItem(
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record RetryPlatformDunningEventCommand(Guid EventId, string? Reason);
|
||||
public sealed record RetryPlatformBillingDunningEventCommand(Guid EventId, string? Reason);
|
||||
|
||||
public interface IPlatformAdminService
|
||||
{
|
||||
Task<PlatformOverview> GetOverviewAsync(PlatformAdminActor actor, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantList> GetTenantsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantDetail> GetTenantDetailAsync(PlatformAdminActor actor, Guid tenantId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantItem> CreateTenantAsync(PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantProvisioningResult> CreateTenantAsync(PlatformAdminActor actor, CreatePlatformTenantCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantItem> UpdateTenantStatusAsync(PlatformAdminActor actor, UpdatePlatformTenantStatusCommand command, CancellationToken cancellationToken = default);
|
||||
Task<TenantBillingProfileItem> UpsertTenantBillingProfileAsync(PlatformAdminActor actor, UpsertPlatformTenantBillingProfileCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformPlanList> GetPlansAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformPlanModuleEntitlements> ReplacePlanModulesAsync(PlatformAdminActor actor, ReplacePlatformPlanModulesCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantSubscriptionItem> UpsertSubscriptionAsync(PlatformAdminActor actor, UpsertPlatformSubscriptionCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformTenantModuleOverrideItem> UpsertTenantModuleOverrideAsync(PlatformAdminActor actor, UpsertPlatformTenantModuleOverrideCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDomainList> GetDomainsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDomainRecheckResult> RecheckDomainAsync(PlatformAdminActor actor, Guid domainId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformStaffList> GetStaffAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
@@ -272,12 +244,12 @@ public interface IPlatformAdminService
|
||||
Task<PlatformAuditLogList> GetAuditLogsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformAuditAlertList> GetAuditAlertsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformAuditAlert> UpdateAuditAlertStatusAsync(PlatformAdminActor actor, UpdatePlatformAuditAlertStatusCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningChannelList> GetDunningChannelsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningChannelItem> UpsertDunningChannelAsync(PlatformAdminActor actor, UpsertPlatformDunningChannelCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningChannelItem> DisableDunningChannelAsync(PlatformAdminActor actor, DisablePlatformDunningChannelCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningEventList> GetDunningEventsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningEventItem> GetDunningEventDetailAsync(PlatformAdminActor actor, Guid eventId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformDunningEventItem> RetryDunningEventAsync(PlatformAdminActor actor, RetryPlatformDunningEventCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingDunningChannelList> GetBillingDunningChannelsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingDunningChannelItem> UpsertBillingDunningChannelAsync(PlatformAdminActor actor, UpsertPlatformBillingDunningChannelCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingDunningChannelItem> DisableBillingDunningChannelAsync(PlatformAdminActor actor, DisablePlatformBillingDunningChannelCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingDunningEventList> GetBillingDunningEventsAsync(PlatformAdminActor actor, PlatformAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingDunningEventItem> GetBillingDunningEventDetailAsync(PlatformAdminActor actor, Guid eventId, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingDunningEventItem> RetryBillingDunningEventAsync(PlatformAdminActor actor, RetryPlatformBillingDunningEventCommand command, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class PlatformAdminException(string message, string code) : Exception(message)
|
||||
|
||||
242
Tiku.Application/PlatformBilling/PlatformBillingContracts.cs
Normal file
242
Tiku.Application/PlatformBilling/PlatformBillingContracts.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Commerce;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Platform;
|
||||
|
||||
namespace Tiku.Application.PlatformBilling;
|
||||
|
||||
public sealed record SaasCatalogActor(Guid UserId);
|
||||
public sealed record TenantBillingActor(Guid UserId, Guid TenantId);
|
||||
|
||||
public sealed record UpsertSaasFeatureCommand(
|
||||
Guid? Id,
|
||||
string Code,
|
||||
string Name,
|
||||
string Category,
|
||||
string? Description,
|
||||
int ReferencePriceCents,
|
||||
string Currency,
|
||||
SaasFeatureStatus Status,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record UpsertSaasOfferingCommand(
|
||||
Guid? Id,
|
||||
string Code,
|
||||
string Name,
|
||||
SaasOfferingType Type,
|
||||
SaasOfferingStatus Status,
|
||||
string? Description,
|
||||
int SortOrder);
|
||||
|
||||
public sealed record UpsertSaasFeatureLimitCommand(
|
||||
Guid? Id,
|
||||
string MetricCode,
|
||||
string FeatureCode,
|
||||
string Name,
|
||||
string Unit,
|
||||
SaasFeatureLimitKind Kind,
|
||||
int WarningPercent,
|
||||
bool IsHardLimit);
|
||||
|
||||
public sealed record UpsertSaasOfferingVersionCommand(
|
||||
Guid? Id,
|
||||
Guid OfferingId,
|
||||
PlatformBillingCycle BillingCycle,
|
||||
int OriginalAmountCents,
|
||||
int AmountCents,
|
||||
string Currency,
|
||||
DateTimeOffset? EffectiveAt,
|
||||
IReadOnlyCollection<string> FeatureCodes,
|
||||
IReadOnlyDictionary<string, long> Limits,
|
||||
JsonElement Metadata);
|
||||
|
||||
public sealed record SaasOfferingVersionItem(
|
||||
Guid Id,
|
||||
Guid OfferingId,
|
||||
string OfferingCode,
|
||||
string OfferingName,
|
||||
SaasOfferingType OfferingType,
|
||||
int Version,
|
||||
SaasOfferingVersionStatus Status,
|
||||
PlatformBillingCycle BillingCycle,
|
||||
int OriginalAmountCents,
|
||||
int AmountCents,
|
||||
string Currency,
|
||||
DateTimeOffset? EffectiveAt,
|
||||
DateTimeOffset? PublishedAt,
|
||||
IReadOnlyCollection<string> FeatureCodes,
|
||||
IReadOnlyDictionary<string, long> Limits);
|
||||
|
||||
public sealed record SaasCatalogSnapshot(
|
||||
IReadOnlyCollection<SaasFeature> Features,
|
||||
IReadOnlyCollection<SaasFeatureLimitDefinition> LimitDefinitions,
|
||||
IReadOnlyCollection<SaasOffering> Offerings,
|
||||
IReadOnlyCollection<SaasOfferingVersionItem> Versions);
|
||||
|
||||
public interface ISaasCatalogAdminService
|
||||
{
|
||||
Task<SaasCatalogSnapshot> GetCatalogAsync(SaasCatalogActor actor, CancellationToken cancellationToken = default);
|
||||
Task<SaasFeature> UpsertFeatureAsync(SaasCatalogActor actor, UpsertSaasFeatureCommand command, CancellationToken cancellationToken = default);
|
||||
Task<SaasFeatureLimitDefinition> UpsertLimitDefinitionAsync(SaasCatalogActor actor, UpsertSaasFeatureLimitCommand command, CancellationToken cancellationToken = default);
|
||||
Task<SaasOffering> UpsertOfferingAsync(SaasCatalogActor actor, UpsertSaasOfferingCommand command, CancellationToken cancellationToken = default);
|
||||
Task<SaasOfferingVersionItem> UpsertDraftVersionAsync(SaasCatalogActor actor, UpsertSaasOfferingVersionCommand command, CancellationToken cancellationToken = default);
|
||||
Task<SaasOfferingVersionItem> PublishVersionAsync(SaasCatalogActor actor, Guid versionId, CancellationToken cancellationToken = default);
|
||||
Task<SaasOfferingVersionItem> CloneVersionAsync(SaasCatalogActor actor, Guid versionId, CancellationToken cancellationToken = default);
|
||||
Task<SaasOfferingVersionItem> RetireVersionAsync(SaasCatalogActor actor, Guid versionId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record TenantBillingCatalog(
|
||||
IReadOnlyCollection<SaasFeature> Features,
|
||||
IReadOnlyCollection<SaasOfferingVersionItem> BasePlans,
|
||||
IReadOnlyCollection<SaasOfferingVersionItem> AddOns);
|
||||
|
||||
public sealed record CreatePlatformBillingQuoteCommand(
|
||||
Guid BaseOfferingVersionId,
|
||||
IReadOnlyCollection<Guid> AddOnOfferingVersionIds,
|
||||
PlatformBillingOrderPurpose Purpose,
|
||||
string IdempotencyKey);
|
||||
|
||||
public sealed record PlatformBillingQuoteItemView(
|
||||
Guid OfferingVersionId,
|
||||
string OfferingCode,
|
||||
string OfferingName,
|
||||
PlatformBillingItemType ItemType,
|
||||
int UnitAmountCents,
|
||||
int AmountCents);
|
||||
|
||||
public sealed record PlatformBillingQuoteView(
|
||||
Guid Id,
|
||||
string QuoteNo,
|
||||
PlatformBillingQuoteStatus Status,
|
||||
PlatformBillingOrderPurpose Purpose,
|
||||
int OriginalAmountCents,
|
||||
int DiscountAmountCents,
|
||||
int TotalAmountCents,
|
||||
string Currency,
|
||||
DateTimeOffset ExpiresAt,
|
||||
IReadOnlyCollection<PlatformBillingQuoteItemView> Items,
|
||||
IReadOnlyCollection<string> FeatureCodes,
|
||||
IReadOnlyDictionary<string, long> Limits);
|
||||
|
||||
public sealed record CreatePlatformBillingOrderCommand(Guid QuoteId, string IdempotencyKey);
|
||||
public sealed record CreatePlatformBillingPaymentCommand(
|
||||
string OrderNo,
|
||||
string Provider,
|
||||
string Method,
|
||||
string IdempotencyKey,
|
||||
string? OpenId,
|
||||
string? ReturnUrl,
|
||||
string? QuitUrl);
|
||||
|
||||
public sealed record PlatformBillingOrderView(
|
||||
Guid Id,
|
||||
string OrderNo,
|
||||
PlatformBillingOrderPurpose Purpose,
|
||||
PlatformBillingOrderStatus Status,
|
||||
int TotalAmountCents,
|
||||
string Currency,
|
||||
DateTimeOffset ExpiresAt,
|
||||
DateTimeOffset? PaidAt,
|
||||
IReadOnlyCollection<PlatformBillingQuoteItemView> Items);
|
||||
|
||||
public sealed record PlatformBillingPaymentView(
|
||||
Guid Id,
|
||||
string PaymentNo,
|
||||
string Provider,
|
||||
string Method,
|
||||
PlatformBillingPaymentStatus Status,
|
||||
int AmountCents,
|
||||
JsonElement ClientPayload);
|
||||
|
||||
public sealed record TenantSubscriptionView(
|
||||
Guid Id,
|
||||
TenantSaasSubscriptionStatus Status,
|
||||
DateTimeOffset StartsAt,
|
||||
DateTimeOffset CurrentPeriodStart,
|
||||
DateTimeOffset CurrentPeriodEnd,
|
||||
bool CancelAtPeriodEnd,
|
||||
Guid BaseOfferingVersionId,
|
||||
Guid? ScheduledBaseOfferingVersionId,
|
||||
IReadOnlyCollection<string> FeatureCodes);
|
||||
|
||||
public sealed record ChangeTenantSubscriptionCommand(Guid BaseOfferingVersionId, IReadOnlyCollection<Guid> AddOnOfferingVersionIds);
|
||||
|
||||
public interface ITenantBillingService
|
||||
{
|
||||
Task<TenantBillingCatalog> GetCatalogAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingQuoteView> CreateQuoteAsync(TenantBillingActor actor, CreatePlatformBillingQuoteCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingOrderView> CreateOrderAsync(TenantBillingActor actor, CreatePlatformBillingOrderCommand command, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingPaymentView> CreatePaymentAsync(TenantBillingActor actor, CreatePlatformBillingPaymentCommand command, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformBillingOrderView>> GetOrdersAsync(TenantBillingActor actor, int limit, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingOrderView> GetOrderAsync(TenantBillingActor actor, string orderNo, CancellationToken cancellationToken = default);
|
||||
Task<TenantSubscriptionView?> GetSubscriptionAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingOrderView> ChangeSubscriptionAsync(TenantBillingActor actor, ChangeTenantSubscriptionCommand command, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingOrderView> RenewSubscriptionAsync(TenantBillingActor actor, string idempotencyKey, CancellationToken cancellationToken = default);
|
||||
Task<TenantSubscriptionView> CancelSubscriptionAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetUsageAsync(TenantBillingActor actor, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformBillingInvoice>> GetInvoicesAsync(TenantBillingActor actor, int limit, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record PlatformBillingAdminQuery(Guid? TenantId, string? Status, int Limit = 100);
|
||||
public sealed record ConfirmManualPaymentCommand(Guid PaymentId, string? ProviderTradeNo, DateTimeOffset? PaidAt, string Reason);
|
||||
public sealed record UpsertTenantFeatureOverrideCommand(Guid TenantId, string FeatureCode, TenantFeatureOverrideMode Mode, DateTimeOffset? ExpiresAt, string Reason);
|
||||
|
||||
public interface IPlatformBillingAdminService
|
||||
{
|
||||
Task<IReadOnlyCollection<PlatformBillingOrder>> GetOrdersAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformBillingPayment>> GetPaymentsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformBillingRefund>> GetRefundsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<PlatformBillingInvoice>> GetInvoicesAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyCollection<TenantSaasSubscription>> GetSubscriptionsAsync(SaasCatalogActor actor, PlatformBillingAdminQuery query, CancellationToken cancellationToken = default);
|
||||
Task<PlatformBillingPayment> ConfirmManualPaymentAsync(SaasCatalogActor actor, ConfirmManualPaymentCommand command, CancellationToken cancellationToken = default);
|
||||
Task<TenantFeatureOverride> UpsertFeatureOverrideAsync(SaasCatalogActor actor, UpsertTenantFeatureOverrideCommand command, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IPlatformBillingPaymentGateway
|
||||
{
|
||||
Task<CreatePaymentProviderResult> CreatePaymentAsync(string provider, CreatePaymentProviderRequest request, CancellationToken cancellationToken = default);
|
||||
Task<PaymentNotificationResult> ParseNotificationAsync(string provider, PaymentNotificationRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record PlatformBillingNotification(
|
||||
string Provider,
|
||||
IReadOnlyDictionary<string, string> Headers,
|
||||
string RawBody,
|
||||
JsonElement Body);
|
||||
|
||||
public interface IPlatformBillingNotificationService
|
||||
{
|
||||
Task ProcessAsync(PlatformBillingNotification notification, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IPlatformBillingSettlementService
|
||||
{
|
||||
Task<PlatformBillingPayment> MarkPaidAsync(
|
||||
Guid paymentId,
|
||||
string providerEventId,
|
||||
string eventType,
|
||||
string? providerTradeNo,
|
||||
DateTimeOffset paidAt,
|
||||
JsonElement payload,
|
||||
Guid? actorUserId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class SaasSubscriptionLifecycleOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int BatchSize { get; set; } = 100;
|
||||
public int PastDueGraceDays { get; set; } = 7;
|
||||
}
|
||||
|
||||
public interface ISaasSubscriptionLifecycleService
|
||||
{
|
||||
Task<int> ProcessDueAsync(
|
||||
DateTimeOffset? asOf = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class PlatformBillingException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -6,13 +6,19 @@ public static class BackendPermissions
|
||||
public const string TenantStaffManage = "tenant:staff:manage";
|
||||
public const string TenantRoleManage = "tenant:role:manage";
|
||||
public const string TenantStudentManage = "tenant:student:manage";
|
||||
public const string TenantContentManage = "tenant:content:manage";
|
||||
public const string TenantContentManage = "tenant:question-bank:manage";
|
||||
public const string TenantVocabularyManage = "tenant:vocabulary:manage";
|
||||
public const string TenantHandbookManage = "tenant:handbook:manage";
|
||||
public const string TenantVideoManage = "tenant:video:manage";
|
||||
public const string TenantScorelineManage = "tenant:scoreline:manage";
|
||||
public const string TenantSiteContentManage = "tenant:site-content:manage";
|
||||
public const string TenantSettingsManage = "tenant:settings:manage";
|
||||
public const string TenantProviderManage = "tenant:provider:manage";
|
||||
public const string TenantCommerceOperate = "tenant:commerce:operate";
|
||||
public const string TenantCrmManage = "tenant:crm:manage";
|
||||
public const string TenantCommissionManage = "tenant:commission:manage";
|
||||
public const string TenantJobManage = "tenant:job:manage";
|
||||
public const string TenantBillingManage = "tenant:billing:manage";
|
||||
|
||||
public const string PlatformDashboardView = "platform:dashboard:view";
|
||||
public const string PlatformTenantManage = "platform:tenant:manage";
|
||||
@@ -21,6 +27,8 @@ public static class BackendPermissions
|
||||
public const string PlatformQuestionBankManage = "platform:question-bank:manage";
|
||||
public const string PlatformAuditView = "platform:audit:view";
|
||||
public const string PlatformBillingNotification = "platform:billing:notification";
|
||||
public const string PlatformSaasCatalogManage = "platform:saas-catalog:manage";
|
||||
public const string PlatformSaasBillingManage = "platform:saas-billing:manage";
|
||||
|
||||
public static readonly IReadOnlySet<string> Tenant = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
@@ -29,12 +37,18 @@ public static class BackendPermissions
|
||||
TenantRoleManage,
|
||||
TenantStudentManage,
|
||||
TenantContentManage,
|
||||
TenantVocabularyManage,
|
||||
TenantHandbookManage,
|
||||
TenantVideoManage,
|
||||
TenantScorelineManage,
|
||||
TenantSiteContentManage,
|
||||
TenantSettingsManage,
|
||||
TenantProviderManage,
|
||||
TenantCommerceOperate,
|
||||
TenantCrmManage,
|
||||
TenantCommissionManage,
|
||||
TenantJobManage
|
||||
TenantJobManage,
|
||||
TenantBillingManage
|
||||
};
|
||||
|
||||
public static readonly IReadOnlySet<string> Platform = new HashSet<string>(StringComparer.Ordinal)
|
||||
@@ -45,7 +59,9 @@ public static class BackendPermissions
|
||||
PlatformRoleManage,
|
||||
PlatformQuestionBankManage,
|
||||
PlatformAuditView,
|
||||
PlatformBillingNotification
|
||||
PlatformBillingNotification,
|
||||
PlatformSaasCatalogManage,
|
||||
PlatformSaasBillingManage
|
||||
};
|
||||
|
||||
public static void EnsureTenant(string permissionCode)
|
||||
|
||||
7
Tiku.Application/Security/CapabilityOperation.cs
Normal file
7
Tiku.Application/Security/CapabilityOperation.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public enum CapabilityOperation
|
||||
{
|
||||
Read,
|
||||
Write
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public static class FeatureQuotaConsumptionExtensions
|
||||
{
|
||||
public static async Task<bool> ConsumeQuotaIfConfiguredAsync(
|
||||
this IFeatureAccessService featureAccessService,
|
||||
Guid tenantId,
|
||||
string metricCode,
|
||||
long amount = 1,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var configured = (await featureAccessService.GetQuotaSummaryAsync(tenantId, cancellationToken))
|
||||
.Any(item => string.Equals(item.MetricCode, metricCode, StringComparison.Ordinal));
|
||||
if (!configured)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!await featureAccessService.TryConsumeQuotaAsync(tenantId, metricCode, amount, cancellationToken))
|
||||
{
|
||||
throw new FeatureAccessException("The tenant feature quota has been exhausted.", "feature_quota_exhausted");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public enum CapabilityOperation
|
||||
{
|
||||
Read,
|
||||
Write
|
||||
}
|
||||
|
||||
public interface ICapabilityAccessEvaluator
|
||||
{
|
||||
Task<bool> IsAllowedAsync(
|
||||
Guid tenantId,
|
||||
string moduleCode,
|
||||
CapabilityOperation operation,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlySet<string>> GetEnabledModulesAsync(
|
||||
Guid tenantId,
|
||||
CapabilityOperation operation = CapabilityOperation.Read,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
64
Tiku.Application/Security/IFeatureAccessService.cs
Normal file
64
Tiku.Application/Security/IFeatureAccessService.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public enum FeatureAccessOperation
|
||||
{
|
||||
Read,
|
||||
Write
|
||||
}
|
||||
|
||||
public sealed record FeatureAccessDecision(
|
||||
bool Allowed,
|
||||
string? DenialCode,
|
||||
string FeatureCode,
|
||||
FeatureAccessOperation Operation);
|
||||
|
||||
public sealed record FeatureQuotaSnapshot(
|
||||
string MetricCode,
|
||||
long UsedValue,
|
||||
long LimitValue,
|
||||
int UsedPercent,
|
||||
bool Warning,
|
||||
bool Exceeded,
|
||||
DateTimeOffset PeriodStart,
|
||||
DateTimeOffset PeriodEnd);
|
||||
|
||||
public interface IFeatureAccessService
|
||||
{
|
||||
Task<FeatureAccessDecision> EvaluateAsync(
|
||||
Guid tenantId,
|
||||
string featureCode,
|
||||
FeatureAccessOperation operation,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlySet<string>> GetEnabledFeaturesAsync(
|
||||
Guid tenantId,
|
||||
FeatureAccessOperation operation = FeatureAccessOperation.Read,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlySet<string>> FilterPermissionCodesAsync(
|
||||
Guid tenantId,
|
||||
IEnumerable<string> permissionCodes,
|
||||
FeatureAccessOperation operation = FeatureAccessOperation.Read,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyCollection<FeatureQuotaSnapshot>> GetQuotaSummaryAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> TryConsumeQuotaAsync(
|
||||
Guid tenantId,
|
||||
string metricCode,
|
||||
long amount,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task ReleaseQuotaAsync(
|
||||
Guid tenantId,
|
||||
string metricCode,
|
||||
long amount,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class FeatureAccessException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public sealed record ReconcileFeatureUsageRequest(
|
||||
Guid TenantId,
|
||||
SystemScopeCallerType CallerType,
|
||||
string Caller,
|
||||
string Reason,
|
||||
string CorrelationId);
|
||||
|
||||
public sealed record ReconciledFeatureUsage(
|
||||
string MetricCode,
|
||||
long ActualValue,
|
||||
long LimitValue,
|
||||
bool Warning,
|
||||
bool Exceeded);
|
||||
|
||||
public interface IFeatureUsageReconciliationService
|
||||
{
|
||||
Task<IReadOnlyCollection<ReconciledFeatureUsage>> ReconcileTenantAsync(
|
||||
ReconcileFeatureUsageRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> ProcessDueAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class FeatureUsageReconciliationOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
public int BatchSize { get; set; } = 100;
|
||||
public int IntervalMinutes { get; set; } = 60;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public static class ProductModuleCatalog
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string> All =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["dashboard"] = "Dashboard",
|
||||
["staff"] = "Staff",
|
||||
["role"] = "Roles",
|
||||
["student"] = "Students",
|
||||
["content"] = "Content",
|
||||
["settings"] = "Settings",
|
||||
["provider"] = "Providers",
|
||||
["commerce"] = "Commerce",
|
||||
["crm"] = "CRM",
|
||||
["commission"] = "Commission",
|
||||
["job"] = "Background Jobs"
|
||||
};
|
||||
|
||||
public static bool Contains(string moduleCode) =>
|
||||
All.ContainsKey(moduleCode.Trim().ToLowerInvariant());
|
||||
}
|
||||
138
Tiku.Application/Security/SaasFeatureCatalog.cs
Normal file
138
Tiku.Application/Security/SaasFeatureCatalog.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
namespace Tiku.Application.Security;
|
||||
|
||||
public static class SaasFeatureCatalog
|
||||
{
|
||||
public const string CoreBackoffice = "core.backoffice";
|
||||
public const string PrivateQuestionBank = "question_bank.private";
|
||||
public const string Practice = "learning.practice";
|
||||
public const string Assignment = "learning.assignment";
|
||||
public const string Exam = "learning.exam";
|
||||
public const string Vocabulary = "content.vocabulary";
|
||||
public const string Handbook = "content.handbook";
|
||||
public const string Video = "content.video";
|
||||
public const string Scoreline = "content.scoreline";
|
||||
public const string SiteContent = "marketing.site_content";
|
||||
public const string StudentManagement = "student.management";
|
||||
public const string StudentStore = "commerce.student_store";
|
||||
public const string Crm = "crm.followup";
|
||||
public const string ReferralCommission = "growth.referral_commission";
|
||||
public const string TeacherAi = "ai.teacher_assistant";
|
||||
|
||||
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
CoreBackoffice,
|
||||
PrivateQuestionBank,
|
||||
Practice,
|
||||
Assignment,
|
||||
Exam,
|
||||
Vocabulary,
|
||||
Handbook,
|
||||
Video,
|
||||
Scoreline,
|
||||
SiteContent,
|
||||
StudentManagement,
|
||||
StudentStore,
|
||||
Crm,
|
||||
ReferralCommission,
|
||||
TeacherAi
|
||||
};
|
||||
|
||||
public static string? ResolveContentImportFeature(string? importType)
|
||||
{
|
||||
var normalized = importType?.Trim().ToLowerInvariant().Replace('-', '_');
|
||||
return normalized switch
|
||||
{
|
||||
"question" or "questions" or "question_bank" or "question_banks" => PrivateQuestionBank,
|
||||
"vocabulary" or "vocabulary_unit" or "vocabulary_units" or "vocabulary_word" or "vocabulary_words" => Vocabulary,
|
||||
"handbook" or "handbook_subject" or "handbook_subjects" or "handbook_chapter" or "handbook_chapters" or
|
||||
"handbook_entry" or "handbook_entries" => Handbook,
|
||||
"scoreline" or "scorelines" => Scoreline,
|
||||
"video" or "videos" => Video,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class PermissionModuleCatalog
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string?> RequiredFeatures =
|
||||
new Dictionary<string, string?>(StringComparer.Ordinal)
|
||||
{
|
||||
["tenant_dashboard"] = null,
|
||||
["tenant_staff"] = null,
|
||||
["tenant_settings"] = null,
|
||||
["tenant_provider"] = null,
|
||||
["tenant_job"] = null,
|
||||
["tenant_billing"] = null,
|
||||
["tenant_student"] = SaasFeatureCatalog.StudentManagement,
|
||||
["tenant_question_bank"] = SaasFeatureCatalog.PrivateQuestionBank,
|
||||
["tenant_vocabulary"] = SaasFeatureCatalog.Vocabulary,
|
||||
["tenant_handbook"] = SaasFeatureCatalog.Handbook,
|
||||
["tenant_video"] = SaasFeatureCatalog.Video,
|
||||
["tenant_scoreline"] = SaasFeatureCatalog.Scoreline,
|
||||
["tenant_site_content"] = SaasFeatureCatalog.SiteContent,
|
||||
["tenant_commerce"] = SaasFeatureCatalog.StudentStore,
|
||||
["tenant_crm"] = SaasFeatureCatalog.Crm,
|
||||
["tenant_commission"] = SaasFeatureCatalog.ReferralCommission,
|
||||
["platform_dashboard"] = null,
|
||||
["platform_tenant"] = null,
|
||||
["platform_staff"] = null,
|
||||
["platform_content"] = null,
|
||||
["platform_audit"] = null,
|
||||
["platform_billing"] = null,
|
||||
["commerce"] = SaasFeatureCatalog.StudentStore
|
||||
};
|
||||
|
||||
public static string ResolvePermissionModuleCode(string permissionCode) => permissionCode switch
|
||||
{
|
||||
BackendPermissions.TenantDashboardView => "tenant_dashboard",
|
||||
BackendPermissions.TenantStaffManage or BackendPermissions.TenantRoleManage => "tenant_staff",
|
||||
BackendPermissions.TenantStudentManage => "tenant_student",
|
||||
BackendPermissions.TenantContentManage => "tenant_question_bank",
|
||||
BackendPermissions.TenantVocabularyManage => "tenant_vocabulary",
|
||||
BackendPermissions.TenantHandbookManage => "tenant_handbook",
|
||||
BackendPermissions.TenantVideoManage => "tenant_video",
|
||||
BackendPermissions.TenantScorelineManage => "tenant_scoreline",
|
||||
BackendPermissions.TenantSiteContentManage => "tenant_site_content",
|
||||
BackendPermissions.TenantSettingsManage => "tenant_settings",
|
||||
BackendPermissions.TenantProviderManage => "tenant_provider",
|
||||
BackendPermissions.TenantCommerceOperate => "tenant_commerce",
|
||||
BackendPermissions.TenantCrmManage => "tenant_crm",
|
||||
BackendPermissions.TenantCommissionManage => "tenant_commission",
|
||||
BackendPermissions.TenantJobManage => "tenant_job",
|
||||
BackendPermissions.TenantBillingManage => "tenant_billing",
|
||||
BackendPermissions.PlatformDashboardView => "platform_dashboard",
|
||||
BackendPermissions.PlatformTenantManage => "platform_tenant",
|
||||
BackendPermissions.PlatformStaffManage or BackendPermissions.PlatformRoleManage => "platform_staff",
|
||||
BackendPermissions.PlatformQuestionBankManage => "platform_content",
|
||||
BackendPermissions.PlatformAuditView => "platform_audit",
|
||||
BackendPermissions.PlatformBillingNotification => "platform_billing",
|
||||
BackendPermissions.PlatformSaasCatalogManage or BackendPermissions.PlatformSaasBillingManage => "platform_billing",
|
||||
_ when permissionCode.StartsWith("commerce:", StringComparison.Ordinal) => "commerce",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(permissionCode), permissionCode, "Permission module mapping is missing.")
|
||||
};
|
||||
}
|
||||
|
||||
public static class SaasQuotaMetricCatalog
|
||||
{
|
||||
public const string StaffCount = "staff.count";
|
||||
public const string StudentCount = "student.count";
|
||||
public const string PrivateQuestionCount = "private_question.count";
|
||||
public const string StorageBytes = "storage.bytes";
|
||||
public const string ImportCount = "import.count";
|
||||
public const string ExportCount = "export.count";
|
||||
public const string SmsCount = "sms.count";
|
||||
public const string AiCallCount = "ai.call.count";
|
||||
|
||||
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
StaffCount,
|
||||
StudentCount,
|
||||
PrivateQuestionCount,
|
||||
StorageBytes,
|
||||
ImportCount,
|
||||
ExportCount,
|
||||
SmsCount,
|
||||
AiCallCount
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ public static class TikuPolicies
|
||||
public const string TenantBackofficeBootstrap = "tenant_backoffice_bootstrap";
|
||||
public const string PlatformBackofficeBootstrap = "platform_backoffice_bootstrap";
|
||||
public const string TenantContentManageAllScope = "tenant:content:manage:all_scope";
|
||||
public const string TenantAllDataScope = "tenant:data-scope:all";
|
||||
public const string TenantCommerceOperateAllScope = "tenant:commerce:operate:all_scope";
|
||||
|
||||
public const string TenantAdmin = "tenant_admin";
|
||||
|
||||
@@ -25,7 +25,9 @@ public sealed record TenantRuntimeBootstrap(
|
||||
JsonElement Theme,
|
||||
JsonElement Features,
|
||||
JsonElement Navigation,
|
||||
JsonElement HomeModules);
|
||||
JsonElement HomeModules,
|
||||
IReadOnlyCollection<string> EnabledFeatures,
|
||||
IReadOnlyCollection<string> LoginMethods);
|
||||
|
||||
public interface ITenantFrontendConfigService
|
||||
{
|
||||
|
||||
19
Tiku.Application/Tenancy/TenantOnboardingModels.cs
Normal file
19
Tiku.Application/Tenancy/TenantOnboardingModels.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace Tiku.Application.Tenancy;
|
||||
|
||||
public sealed record TenantOnboardingStep(
|
||||
string Code,
|
||||
bool Required,
|
||||
bool Completed,
|
||||
string? Detail);
|
||||
|
||||
public sealed record TenantOnboardingStatus(
|
||||
Guid TenantId,
|
||||
bool ReadyForStudentTraffic,
|
||||
int CompletedRequiredSteps,
|
||||
int RequiredSteps,
|
||||
IReadOnlyCollection<TenantOnboardingStep> Steps);
|
||||
|
||||
public interface ITenantOnboardingService
|
||||
{
|
||||
Task<TenantOnboardingStatus> GetStatusAsync(Guid tenantId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -193,28 +193,6 @@ public sealed class CouponRedemption : AuditableTenantEntity
|
||||
public DateTimeOffset? UsedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TenantSubscription : AuditableTenantEntity
|
||||
{
|
||||
public string PlanCode { get; set; } = string.Empty;
|
||||
public TenantSubscriptionStatus Status { get; set; } = TenantSubscriptionStatus.Trial;
|
||||
public DateTimeOffset? StartsAt { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public string? BillingCycle { get; set; } = "yearly";
|
||||
public int AmountCents { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class TenantUsageRecord : Entity, ITenantOwned
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public string MetricKey { get; set; } = string.Empty;
|
||||
public decimal MetricValue { get; set; }
|
||||
public DateOnly PeriodStart { get; set; }
|
||||
public DateOnly PeriodEnd { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class CommerceRefundRequest : AuditableTenantEntity
|
||||
{
|
||||
public Guid OrderId { get; set; }
|
||||
@@ -389,7 +367,6 @@ public enum EntitlementScopeType { Tenant, Region, Module, Subject, QuestionBank
|
||||
public enum EntitlementStatus { Active, Revoked, Expired }
|
||||
public enum DiscountType { Percent, Fixed }
|
||||
public enum CouponRedemptionStatus { Claimed, Used, Expired, Cancelled }
|
||||
public enum TenantSubscriptionStatus { Trial, Active, PastDue, Cancelled, Expired }
|
||||
public enum CommerceRefundStatus { Requested, Approved, Processing, Succeeded, Failed, Rejected, Cancelled }
|
||||
public enum RefundEntitlementAction { None, RevokeOnSuccess }
|
||||
public enum ReconciliationBillType { Payment, Refund, Combined }
|
||||
|
||||
@@ -56,7 +56,7 @@ public sealed class BackendPermission : AuditableEntity
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public BackendPermissionArea Area { get; set; } = BackendPermissionArea.Tenant;
|
||||
public string Module { get; set; } = string.Empty;
|
||||
public string PermissionModuleCode { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool IsSystem { get; set; } = true;
|
||||
public int SortOrder { get; set; } = 100;
|
||||
|
||||
@@ -3,45 +3,6 @@ using Tiku.Domain.Common;
|
||||
|
||||
namespace Tiku.Domain.Platform;
|
||||
|
||||
public sealed class PlatformSaasPlan : AuditableEntity
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public PlatformBillingCycle BillingCycle { get; set; } = PlatformBillingCycle.Yearly;
|
||||
public int BaseAmountCents { get; set; }
|
||||
public string Currency { get; set; } = "CNY";
|
||||
public JsonElement IncludedQuotas { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement OveragePrices { get; set; } = JsonDefaults.Object();
|
||||
public JsonElement FeatureFlags { get; set; } = JsonDefaults.Object();
|
||||
public PlatformSaasPlanStatus Status { get; set; } = PlatformSaasPlanStatus.Active;
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ProductModule : AuditableEntity
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public ProductModuleStatus Status { get; set; } = ProductModuleStatus.Active;
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlanModuleEntitlement : Entity
|
||||
{
|
||||
public string PlanCode { get; set; } = string.Empty;
|
||||
public string ModuleCode { get; set; } = string.Empty;
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class TenantModuleOverride : AuditableTenantEntity
|
||||
{
|
||||
public string ModuleCode { get; set; } = string.Empty;
|
||||
public TenantModuleOverrideMode Mode { get; set; } = TenantModuleOverrideMode.Disabled;
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TenantBillingProfile : IHasTimestamps, ITenantOwned
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
@@ -52,7 +13,7 @@ public sealed class TenantBillingProfile : IHasTimestamps, ITenantOwned
|
||||
public string? ContactEmail { get; set; }
|
||||
public string? BillingAddress { get; set; }
|
||||
public string? InvoiceTitle { get; set; }
|
||||
public TenantInvoiceTitleType? InvoiceType { get; set; }
|
||||
public TenantBillingInvoiceTitleType? InvoiceType { get; set; }
|
||||
public string? BankName { get; set; }
|
||||
public string? BankAccountMasked { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
@@ -60,63 +21,13 @@ public sealed class TenantBillingProfile : IHasTimestamps, ITenantOwned
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class TenantInvoice : AuditableTenantEntity
|
||||
{
|
||||
public Guid? CreatedBy { get; set; }
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
public TenantInvoiceType InvoiceType { get; set; } = TenantInvoiceType.Subscription;
|
||||
public TenantInvoiceStatus Status { get; set; } = TenantInvoiceStatus.Draft;
|
||||
public string Currency { get; set; } = "CNY";
|
||||
public int SubtotalCents { get; set; }
|
||||
public int DiscountCents { get; set; }
|
||||
public int TaxCents { get; set; }
|
||||
public int TotalCents { get; set; }
|
||||
public int PaidCents { get; set; }
|
||||
public int BalanceCents { get; set; }
|
||||
public DateOnly? BillingPeriodStart { get; set; }
|
||||
public DateOnly? BillingPeriodEnd { get; set; }
|
||||
public DateOnly? DueDate { get; set; }
|
||||
public DateTimeOffset? IssuedAt { get; set; }
|
||||
public DateTimeOffset? PaidAt { get; set; }
|
||||
public string? Note { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class TenantInvoiceItem : Entity, ITenantOwned
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid InvoiceId { get; set; }
|
||||
public string ItemType { get; set; } = "subscription";
|
||||
public Guid? ItemRefId { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; } = 1;
|
||||
public int UnitAmountCents { get; set; }
|
||||
public int AmountCents { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class TenantInvoicePayment : AuditableTenantEntity
|
||||
{
|
||||
public Guid InvoiceId { get; set; }
|
||||
public Guid? ReceivedBy { get; set; }
|
||||
public string PaymentNo { get; set; } = string.Empty;
|
||||
public string Provider { get; set; } = "manual";
|
||||
public string? Method { get; set; }
|
||||
public TenantInvoicePaymentStatus Status { get; set; } = TenantInvoicePaymentStatus.Paid;
|
||||
public int AmountCents { get; set; }
|
||||
public DateTimeOffset? PaidAt { get; set; }
|
||||
public string? ProviderTradeNo { get; set; }
|
||||
public JsonElement RawPayload { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class TenantInvoiceReminder : AuditableTenantEntity
|
||||
public sealed class PlatformBillingInvoiceReminder : AuditableTenantEntity
|
||||
{
|
||||
public Guid InvoiceId { get; set; }
|
||||
public Guid? CreatedBy { get; set; }
|
||||
public TenantInvoiceReminderType ReminderType { get; set; } = TenantInvoiceReminderType.Overdue;
|
||||
public TenantInvoiceReminderChannel Channel { get; set; } = TenantInvoiceReminderChannel.Manual;
|
||||
public TenantInvoiceReminderStatus Status { get; set; } = TenantInvoiceReminderStatus.Pending;
|
||||
public PlatformBillingInvoiceReminderType ReminderType { get; set; } = PlatformBillingInvoiceReminderType.Overdue;
|
||||
public PlatformBillingInvoiceReminderChannel Channel { get; set; } = PlatformBillingInvoiceReminderChannel.Manual;
|
||||
public PlatformBillingInvoiceReminderStatus Status { get; set; } = PlatformBillingInvoiceReminderStatus.Pending;
|
||||
public DateOnly ReminderDate { get; set; }
|
||||
public int ReminderLevel { get; set; } = 1;
|
||||
public DateOnly? DueDate { get; set; }
|
||||
@@ -163,13 +74,13 @@ public sealed class PlatformAuditAlert : AuditableEntity
|
||||
public string? ResolutionNote { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlatformDunningNotificationChannel : AuditableEntity
|
||||
public sealed class PlatformBillingDunningNotificationChannel : AuditableEntity
|
||||
{
|
||||
public string ChannelCode { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool Enabled { get; set; } = true;
|
||||
public PlatformDunningProvider Provider { get; set; } = PlatformDunningProvider.Generic;
|
||||
public PlatformBillingDunningProvider Provider { get; set; } = PlatformBillingDunningProvider.Generic;
|
||||
public string WebhookUrl { get; set; } = string.Empty;
|
||||
public string? SecretRef { get; set; }
|
||||
public string[] ReminderTypes { get; set; } = ["overdue", "final_notice"];
|
||||
@@ -180,13 +91,13 @@ public sealed class PlatformDunningNotificationChannel : AuditableEntity
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class PlatformDunningNotificationEvent : AuditableTenantEntity
|
||||
public sealed class PlatformBillingDunningNotificationEvent : AuditableTenantEntity
|
||||
{
|
||||
public Guid ChannelId { get; set; }
|
||||
public Guid ReminderId { get; set; }
|
||||
public Guid InvoiceId { get; set; }
|
||||
public PlatformDunningProvider Provider { get; set; } = PlatformDunningProvider.Generic;
|
||||
public PlatformDunningNotificationStatus Status { get; set; } = PlatformDunningNotificationStatus.Pending;
|
||||
public PlatformBillingDunningProvider Provider { get; set; } = PlatformBillingDunningProvider.Generic;
|
||||
public PlatformBillingDunningNotificationStatus Status { get; set; } = PlatformBillingDunningNotificationStatus.Pending;
|
||||
public int Attempts { get; set; }
|
||||
public DateTimeOffset ScheduledAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? NextAttemptAt { get; set; }
|
||||
@@ -200,17 +111,11 @@ public sealed class PlatformDunningNotificationEvent : AuditableTenantEntity
|
||||
}
|
||||
|
||||
public enum PlatformBillingCycle { Monthly, Quarterly, Yearly, OneTime }
|
||||
public enum PlatformSaasPlanStatus { Active, Archived }
|
||||
public enum ProductModuleStatus { Active, Archived }
|
||||
public enum TenantModuleOverrideMode { Enabled, Disabled }
|
||||
public enum TenantInvoiceTitleType { None, NormalVat, SpecialVat }
|
||||
public enum TenantInvoiceType { Subscription, ServiceFee, UsageOverage, ManualAdjustment }
|
||||
public enum TenantInvoiceStatus { Draft, Issued, Paid, Void, Overdue }
|
||||
public enum TenantInvoicePaymentStatus { Pending, Paid, Failed, Refunded }
|
||||
public enum TenantInvoiceReminderType { DueSoon, Overdue, FinalNotice, Manual }
|
||||
public enum TenantInvoiceReminderChannel { Manual, Internal, Sms, Email, Wechat, Crm }
|
||||
public enum TenantInvoiceReminderStatus { Pending, Sent, Acknowledged, Dismissed, Failed }
|
||||
public enum TenantBillingInvoiceTitleType { None, NormalVat, SpecialVat }
|
||||
public enum PlatformBillingInvoiceReminderType { DueSoon, Overdue, FinalNotice, Manual }
|
||||
public enum PlatformBillingInvoiceReminderChannel { Manual, Internal, Sms, Email, Wechat, Crm }
|
||||
public enum PlatformBillingInvoiceReminderStatus { Pending, Sent, Acknowledged, Dismissed, Failed }
|
||||
public enum PlatformAlertSeverity { Low, Medium, High, Critical }
|
||||
public enum PlatformAuditAlertStatus { Open, Acknowledged, Resolved, Ignored }
|
||||
public enum PlatformDunningProvider { Generic, Dingtalk, Feishu, Wecom }
|
||||
public enum PlatformDunningNotificationStatus { Pending, Processing, Sent, Retrying, Failed, Discarded }
|
||||
public enum PlatformBillingDunningProvider { Generic, Dingtalk, Feishu, Wecom }
|
||||
public enum PlatformBillingDunningNotificationStatus { Pending, Processing, Sent, Retrying, Failed, Discarded }
|
||||
|
||||
244
Tiku.Domain/Platform/SaasBillingEntities.cs
Normal file
244
Tiku.Domain/Platform/SaasBillingEntities.cs
Normal file
@@ -0,0 +1,244 @@
|
||||
using System.Text.Json;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Operations;
|
||||
|
||||
namespace Tiku.Domain.Platform;
|
||||
|
||||
public sealed class SaasFeature : AuditableEntity
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Category { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int ReferencePriceCents { get; set; }
|
||||
public string Currency { get; set; } = "CNY";
|
||||
public SaasFeatureStatus Status { get; set; } = SaasFeatureStatus.Draft;
|
||||
public bool IsCore { get; set; }
|
||||
public int SortOrder { get; set; } = 100;
|
||||
}
|
||||
|
||||
public sealed class PermissionModule : AuditableEntity
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public BackendPermissionArea Area { get; set; } = BackendPermissionArea.Tenant;
|
||||
public string? RequiredFeatureCode { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int SortOrder { get; set; } = 100;
|
||||
}
|
||||
|
||||
public sealed class SaasOffering : AuditableEntity
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public SaasOfferingType Type { get; set; } = SaasOfferingType.BasePlan;
|
||||
public SaasOfferingStatus Status { get; set; } = SaasOfferingStatus.Draft;
|
||||
public string? Description { get; set; }
|
||||
public int SortOrder { get; set; } = 100;
|
||||
}
|
||||
|
||||
public sealed class SaasOfferingVersion : AuditableEntity
|
||||
{
|
||||
public Guid OfferingId { get; set; }
|
||||
public int Version { get; set; } = 1;
|
||||
public SaasOfferingVersionStatus Status { get; set; } = SaasOfferingVersionStatus.Draft;
|
||||
public PlatformBillingCycle BillingCycle { get; set; } = PlatformBillingCycle.Yearly;
|
||||
public int OriginalAmountCents { get; set; }
|
||||
public int AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "CNY";
|
||||
public DateTimeOffset? EffectiveAt { get; set; }
|
||||
public DateTimeOffset? PublishedAt { get; set; }
|
||||
public DateTimeOffset? RetiredAt { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class SaasOfferingVersionFeature : Entity
|
||||
{
|
||||
public Guid OfferingVersionId { get; set; }
|
||||
public string FeatureCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SaasFeatureLimitDefinition : AuditableEntity
|
||||
{
|
||||
public string MetricCode { get; set; } = string.Empty;
|
||||
public string FeatureCode { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Unit { get; set; } = "count";
|
||||
public SaasFeatureLimitKind Kind { get; set; } = SaasFeatureLimitKind.Current;
|
||||
public int WarningPercent { get; set; } = 80;
|
||||
public bool IsHardLimit { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class SaasOfferingVersionLimit : Entity
|
||||
{
|
||||
public Guid OfferingVersionId { get; set; }
|
||||
public string MetricCode { get; set; } = string.Empty;
|
||||
public long LimitValue { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TenantSaasSubscription : AuditableTenantEntity
|
||||
{
|
||||
public Guid BaseOfferingVersionId { get; set; }
|
||||
public Guid? ScheduledBaseOfferingVersionId { get; set; }
|
||||
public TenantSaasSubscriptionStatus Status { get; set; } = TenantSaasSubscriptionStatus.Trial;
|
||||
public DateTimeOffset StartsAt { get; set; }
|
||||
public DateTimeOffset CurrentPeriodStart { get; set; }
|
||||
public DateTimeOffset CurrentPeriodEnd { get; set; }
|
||||
public bool CancelAtPeriodEnd { get; set; }
|
||||
public DateTimeOffset? CancelledAt { get; set; }
|
||||
public long LifecycleVersion { get; set; }
|
||||
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class TenantSaasSubscriptionItem : AuditableTenantEntity
|
||||
{
|
||||
public Guid SubscriptionId { get; set; }
|
||||
public Guid OfferingVersionId { get; set; }
|
||||
public Guid? SourceOrderItemId { get; set; }
|
||||
public TenantSaasSubscriptionItemType ItemType { get; set; } = TenantSaasSubscriptionItemType.BasePlan;
|
||||
public TenantSaasSubscriptionItemStatus Status { get; set; } = TenantSaasSubscriptionItemStatus.Active;
|
||||
public DateTimeOffset StartsAt { get; set; }
|
||||
public DateTimeOffset EndsAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TenantFeatureOverride : AuditableTenantEntity
|
||||
{
|
||||
public string FeatureCode { get; set; } = string.Empty;
|
||||
public TenantFeatureOverrideMode Mode { get; set; } = TenantFeatureOverrideMode.Disabled;
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class TenantFeatureUsage : AuditableTenantEntity
|
||||
{
|
||||
public string MetricCode { get; set; } = string.Empty;
|
||||
public DateTimeOffset PeriodStart { get; set; }
|
||||
public DateTimeOffset PeriodEnd { get; set; }
|
||||
public long UsedValue { get; set; }
|
||||
public long LimitValueSnapshot { get; set; }
|
||||
public bool WarningIssued { get; set; }
|
||||
public long Version { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlatformBillingQuote : AuditableTenantEntity
|
||||
{
|
||||
public string QuoteNo { get; set; } = string.Empty;
|
||||
public string IdempotencyKey { get; set; } = string.Empty;
|
||||
public PlatformBillingQuoteStatus Status { get; set; } = PlatformBillingQuoteStatus.Active;
|
||||
public PlatformBillingOrderPurpose Purpose { get; set; } = PlatformBillingOrderPurpose.NewSubscription;
|
||||
public int OriginalAmountCents { get; set; }
|
||||
public int DiscountAmountCents { get; set; }
|
||||
public int TotalAmountCents { get; set; }
|
||||
public string Currency { get; set; } = "CNY";
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public JsonElement FeatureSnapshot { get; set; } = JsonDefaults.Array();
|
||||
public JsonElement LimitSnapshot { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class PlatformBillingQuoteItem : Entity, ITenantOwned
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid QuoteId { get; set; }
|
||||
public Guid OfferingVersionId { get; set; }
|
||||
public PlatformBillingItemType ItemType { get; set; } = PlatformBillingItemType.BasePlan;
|
||||
public int Quantity { get; set; } = 1;
|
||||
public int UnitAmountCents { get; set; }
|
||||
public int AmountCents { get; set; }
|
||||
public JsonElement Snapshot { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class PlatformBillingOrder : AuditableTenantEntity
|
||||
{
|
||||
public Guid QuoteId { get; set; }
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
public string IdempotencyKey { get; set; } = string.Empty;
|
||||
public PlatformBillingOrderPurpose Purpose { get; set; } = PlatformBillingOrderPurpose.NewSubscription;
|
||||
public PlatformBillingOrderStatus Status { get; set; } = PlatformBillingOrderStatus.PendingPayment;
|
||||
public int OriginalAmountCents { get; set; }
|
||||
public int DiscountAmountCents { get; set; }
|
||||
public int TotalAmountCents { get; set; }
|
||||
public string Currency { get; set; } = "CNY";
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
public DateTimeOffset? PaidAt { get; set; }
|
||||
public DateTimeOffset? CancelledAt { get; set; }
|
||||
public JsonElement Snapshot { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class PlatformBillingOrderItem : Entity, ITenantOwned
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid OrderId { get; set; }
|
||||
public Guid OfferingVersionId { get; set; }
|
||||
public PlatformBillingItemType ItemType { get; set; } = PlatformBillingItemType.BasePlan;
|
||||
public int Quantity { get; set; } = 1;
|
||||
public int UnitAmountCents { get; set; }
|
||||
public int AmountCents { get; set; }
|
||||
public JsonElement Snapshot { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class PlatformBillingPayment : AuditableTenantEntity
|
||||
{
|
||||
public Guid OrderId { get; set; }
|
||||
public string PaymentNo { get; set; } = string.Empty;
|
||||
public string IdempotencyKey { get; set; } = string.Empty;
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
public string Method { get; set; } = string.Empty;
|
||||
public PlatformBillingPaymentStatus Status { get; set; } = PlatformBillingPaymentStatus.Pending;
|
||||
public int AmountCents { get; set; }
|
||||
public string? ProviderTradeNo { get; set; }
|
||||
public DateTimeOffset? PaidAt { get; set; }
|
||||
public JsonElement ClientPayload { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public sealed class PlatformBillingPaymentEvent : Entity, ITenantOwned
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid PaymentId { get; set; }
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
public string ProviderEventId { get; set; } = string.Empty;
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
public JsonElement Payload { get; set; } = JsonDefaults.Object();
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PlatformBillingRefund : AuditableTenantEntity
|
||||
{
|
||||
public Guid OrderId { get; set; }
|
||||
public Guid? PaymentId { get; set; }
|
||||
public string RefundNo { get; set; } = string.Empty;
|
||||
public PlatformBillingRefundStatus Status { get; set; } = PlatformBillingRefundStatus.Requested;
|
||||
public int AmountCents { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public string? ProviderRefundNo { get; set; }
|
||||
public DateTimeOffset? CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlatformBillingInvoice : AuditableTenantEntity
|
||||
{
|
||||
public Guid? OrderId { get; set; }
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
public PlatformBillingInvoiceStatus Status { get; set; } = PlatformBillingInvoiceStatus.Draft;
|
||||
public int TotalAmountCents { get; set; }
|
||||
public string Currency { get; set; } = "CNY";
|
||||
public DateOnly? DueDate { get; set; }
|
||||
public DateTimeOffset? IssuedAt { get; set; }
|
||||
public DateTimeOffset? PaidAt { get; set; }
|
||||
public JsonElement BillingProfileSnapshot { get; set; } = JsonDefaults.Object();
|
||||
}
|
||||
|
||||
public enum SaasFeatureStatus { Draft, Active, Archived }
|
||||
public enum SaasOfferingType { BasePlan, AddOn }
|
||||
public enum SaasOfferingStatus { Draft, Active, Archived }
|
||||
public enum SaasOfferingVersionStatus { Draft, Published, Retired }
|
||||
public enum SaasFeatureLimitKind { Current, Period }
|
||||
public enum TenantSaasSubscriptionStatus { Trial, Active, PastDue, Cancelled, Expired, Suspended }
|
||||
public enum TenantSaasSubscriptionItemType { BasePlan, AddOn }
|
||||
public enum TenantSaasSubscriptionItemStatus { Pending, Active, Scheduled, Cancelled, Expired }
|
||||
public enum TenantFeatureOverrideMode { Enabled, Disabled }
|
||||
public enum PlatformBillingQuoteStatus { Active, Converted, Expired, Cancelled }
|
||||
public enum PlatformBillingOrderPurpose { NewSubscription, Renewal, Upgrade, Downgrade, AddOn }
|
||||
public enum PlatformBillingOrderStatus { PendingPayment, Paid, Cancelled, Expired, Refunded }
|
||||
public enum PlatformBillingItemType { BasePlan, AddOn }
|
||||
public enum PlatformBillingPaymentStatus { Pending, Succeeded, Failed, Refunded }
|
||||
public enum PlatformBillingRefundStatus { Requested, Processing, Succeeded, Failed, Cancelled }
|
||||
public enum PlatformBillingInvoiceStatus { Draft, Issued, Paid, Void, Overdue }
|
||||
@@ -63,6 +63,7 @@ public sealed class TenantAuthPolicy : IHasTimestamps, ITenantOwned
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public bool AllowExternalStudentSelfRegistration { get; set; }
|
||||
public string[] AllowedStudentLoginMethods { get; set; } = ["password"];
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
@@ -15,7 +16,8 @@ namespace Tiku.Infrastructure.Assets;
|
||||
public sealed class AssetManagementService(
|
||||
TikuDbContext dbContext,
|
||||
IObjectStorageService objectStorageService,
|
||||
ITenantExternalProviderConfigService providerConfigService) : IAssetManagementService
|
||||
ITenantExternalProviderConfigService providerConfigService,
|
||||
IFeatureAccessService featureAccessService) : IAssetManagementService
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 500;
|
||||
@@ -101,6 +103,7 @@ public sealed class AssetManagementService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var asset = await ResolveManagementAssetAsync(actor, command, cancellationToken);
|
||||
var accountedBytesBefore = AccountedStorageBytes(asset);
|
||||
asset.RegionId = command.RegionId;
|
||||
asset.SubjectId = command.SubjectId;
|
||||
asset.CategoryId = command.CategoryId;
|
||||
@@ -131,7 +134,11 @@ public sealed class AssetManagementService(
|
||||
asset.Metadata = command.Metadata.ValueKind == JsonValueKind.Undefined ? asset.Metadata : command.Metadata;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var accountedBytesAfter = AccountedStorageBytes(asset);
|
||||
await SaveWithStorageQuotaAdjustmentAsync(
|
||||
actor.TenantId,
|
||||
accountedBytesAfter - accountedBytesBefore,
|
||||
cancellationToken);
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
@@ -168,8 +175,6 @@ public sealed class AssetManagementService(
|
||||
asset.UploadStatus = AssetUploadStatus.Pending;
|
||||
asset.VerifiedAt = null;
|
||||
asset.VerifiedBy = null;
|
||||
asset.VerifiedSizeBytes = null;
|
||||
asset.VerifiedChecksumSha256 = null;
|
||||
asset.VerificationDetails = JsonDefaults.Object();
|
||||
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
|
||||
asset.PreviewStatus = ResolveInitialPreviewStatus(asset.AssetType, mimeType);
|
||||
@@ -216,6 +221,7 @@ public sealed class AssetManagementService(
|
||||
throw new AssetManagementException("Asset does not have a writable object location.", "asset_location_missing");
|
||||
}
|
||||
|
||||
var accountedBytesBefore = AccountedStorageBytes(asset);
|
||||
var provider = ToObjectStorageProvider(asset.StorageProvider);
|
||||
var declaredMimeType = string.IsNullOrWhiteSpace(command.MimeType) ? asset.MimeType : command.MimeType.Trim();
|
||||
var declaredSize = command.FileSizeBytes ?? asset.FileSizeBytes;
|
||||
@@ -255,21 +261,68 @@ public sealed class AssetManagementService(
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new AssetManagementException("Uploaded object was not found in object storage.", "asset_upload_missing");
|
||||
}
|
||||
if (metadata.SizeBytes is not { } verifiedSizeBytes)
|
||||
{
|
||||
asset.UploadStatus = AssetUploadStatus.Failed;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
throw new AssetManagementException(
|
||||
"Object storage did not return a verified asset size.",
|
||||
"asset_upload_size_unverified");
|
||||
}
|
||||
|
||||
asset.UploadStatus = AssetUploadStatus.Verified;
|
||||
asset.VerifiedAt = DateTimeOffset.UtcNow;
|
||||
asset.VerifiedBy = actor.UserId;
|
||||
asset.VerifiedSizeBytes = metadata.SizeBytes ?? declaredSize;
|
||||
asset.VerifiedSizeBytes = verifiedSizeBytes;
|
||||
asset.VerifiedChecksumSha256 = NormalizeChecksum(metadata.ChecksumSha256) ?? declaredChecksum;
|
||||
asset.MimeType = metadata.MimeType ?? declaredMimeType;
|
||||
asset.FileSizeBytes = metadata.SizeBytes ?? asset.FileSizeBytes;
|
||||
asset.FileSizeBytes = verifiedSizeBytes;
|
||||
asset.SecurityScanStatus = AssetSecurityScanStatus.Pending;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
var accountedBytesAfter = AccountedStorageBytes(asset);
|
||||
await SaveWithStorageQuotaAdjustmentAsync(
|
||||
actor.TenantId,
|
||||
accountedBytesAfter - accountedBytesBefore,
|
||||
cancellationToken);
|
||||
|
||||
return new AssetUploadConfirmResult(ToItem(asset), metadata);
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<ContentAssetManagementItem>> ArchiveAssetAsync(
|
||||
AssetManagementActor actor,
|
||||
Guid assetId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var asset = await dbContext.ContentAssets.SingleOrDefaultAsync(
|
||||
item => item.TenantId == actor.TenantId && item.Id == assetId,
|
||||
cancellationToken);
|
||||
if (asset is null)
|
||||
{
|
||||
throw new AssetManagementException("Asset was not found.", "asset_not_found");
|
||||
}
|
||||
|
||||
if (asset.Status == ContentStatus.Archived)
|
||||
{
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
var accountedBytes = AccountedStorageBytes(asset);
|
||||
asset.Status = ContentStatus.Archived;
|
||||
asset.UpdatedBy = actor.UserId;
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (accountedBytes > 0)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
accountedBytes,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
return new ContentManagementResult<ContentAssetManagementItem>(ToItem(asset));
|
||||
}
|
||||
|
||||
public Task<AssetManagementSignedAccessResult> SignDownloadAsync(
|
||||
AssetManagementActor actor,
|
||||
AssetAccessSignCommand command,
|
||||
@@ -639,6 +692,59 @@ public sealed class AssetManagementService(
|
||||
asset.UpdatedAt);
|
||||
}
|
||||
|
||||
private async Task SaveWithStorageQuotaAdjustmentAsync(
|
||||
Guid tenantId,
|
||||
long byteDelta,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (byteDelta > 0)
|
||||
{
|
||||
var reserved = await featureAccessService.TryConsumeQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
byteDelta,
|
||||
cancellationToken);
|
||||
if (!reserved)
|
||||
{
|
||||
throw new FeatureAccessException(
|
||||
"Tenant storage quota is exhausted.",
|
||||
"feature_quota_exhausted");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
byteDelta,
|
||||
CancellationToken.None);
|
||||
throw;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (byteDelta < 0)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StorageBytes,
|
||||
-byteDelta,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static long AccountedStorageBytes(ContentAsset asset)
|
||||
{
|
||||
return asset.Status == ContentStatus.Active && asset.VerifiedSizeBytes is > 0
|
||||
? asset.VerifiedSizeBytes.Value
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static ContentImportJobItem ToJobItem(ContentImportJob job)
|
||||
{
|
||||
return new ContentImportJobItem(
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Tiku.Application.Auth;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
@@ -19,7 +20,8 @@ public sealed class AuthService(
|
||||
ISmsVerificationService smsVerificationService,
|
||||
IAuthSessionStore sessionStore,
|
||||
IWechatOAuthClient wechatOAuthClient,
|
||||
ITenantExternalProviderConfigService providerConfigService) : IAuthService
|
||||
ITenantExternalProviderConfigService providerConfigService,
|
||||
IFeatureAccessService featureAccessService) : IAuthService
|
||||
{
|
||||
private const string PasswordProvider = "password";
|
||||
private const string SmsProvider = "sms";
|
||||
@@ -393,6 +395,9 @@ public sealed class AuthService(
|
||||
throw;
|
||||
}
|
||||
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var providerSubject = $"{config.AppId}:{identity.OpenId}";
|
||||
var user = await UpsertWechatUserAsync(
|
||||
provider,
|
||||
@@ -408,6 +413,10 @@ public sealed class AuthService(
|
||||
// tenant policy and existing membership state have accepted the login.
|
||||
// A denied first login must not leave a user or provider identity behind.
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return await CompleteSuccessfulLoginAsync(
|
||||
request.Realm,
|
||||
@@ -568,6 +577,11 @@ public sealed class AuthService(
|
||||
throw new TenantAccessDeniedException();
|
||||
}
|
||||
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
tenantId,
|
||||
SaasQuotaMetricCatalog.StudentCount,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
dbContext.TenantMemberships.Add(new TenantMembership
|
||||
{
|
||||
TenantId = tenantId,
|
||||
|
||||
@@ -17,13 +17,15 @@ public sealed class SmsVerificationService(
|
||||
TikuDbContext dbContext,
|
||||
ISmsProvider smsProvider,
|
||||
IRedisSecurityStore redisSecurityStore,
|
||||
IFeatureAccessService featureAccessService,
|
||||
IOptions<SmsSecurityOptions> securityOptions) : ISmsVerificationService
|
||||
{
|
||||
public SmsVerificationService(
|
||||
TikuDbContext dbContext,
|
||||
ISmsProvider smsProvider,
|
||||
IFeatureAccessService featureAccessService,
|
||||
IOptions<SmsSecurityOptions> securityOptions)
|
||||
: this(dbContext, smsProvider, new NullRedisSecurityStore(), securityOptions)
|
||||
: this(dbContext, smsProvider, new NullRedisSecurityStore(), featureAccessService, securityOptions)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -40,6 +42,17 @@ public sealed class SmsVerificationService(
|
||||
var phone = SmsCodeHashing.NormalizePhone(request.Phone);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
await ConsumeRateLimitsAsync(request, phone, now, cancellationToken);
|
||||
var quotaReserved = await featureAccessService.TryConsumeQuotaAsync(
|
||||
request.TenantId,
|
||||
SaasQuotaMetricCatalog.SmsCount,
|
||||
1,
|
||||
cancellationToken);
|
||||
if (!quotaReserved)
|
||||
{
|
||||
throw new FeatureAccessException(
|
||||
"Tenant SMS quota is exhausted.",
|
||||
"feature_quota_exhausted");
|
||||
}
|
||||
|
||||
var code = RandomNumberGenerator
|
||||
.GetInt32(100000, 1000000)
|
||||
@@ -52,6 +65,7 @@ public sealed class SmsVerificationService(
|
||||
options.CodePepper);
|
||||
|
||||
SmsProviderSendResult sendResult;
|
||||
var providerAccepted = false;
|
||||
try
|
||||
{
|
||||
sendResult = await smsProvider.SendAsync(
|
||||
@@ -63,6 +77,7 @@ public sealed class SmsVerificationService(
|
||||
request.IpAddress,
|
||||
request.UserAgent),
|
||||
cancellationToken);
|
||||
providerAccepted = true;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
@@ -98,6 +113,17 @@ public sealed class SmsVerificationService(
|
||||
"sms_provider_send_failed",
|
||||
exception);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!providerAccepted)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
request.TenantId,
|
||||
SaasQuotaMetricCatalog.SmsCount,
|
||||
1,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
await ExpirePreviousCodesAsync(
|
||||
request.TenantId,
|
||||
|
||||
@@ -4,6 +4,7 @@ using Tiku.Application.Backoffice;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
@@ -12,27 +13,82 @@ namespace Tiku.Infrastructure.Backoffice;
|
||||
internal sealed class BackofficeService(
|
||||
TikuDbContext dbContext,
|
||||
IOperationAuditService auditService,
|
||||
ICapabilityAccessEvaluator capabilityAccessEvaluator) : IBackofficeService
|
||||
IFeatureAccessService featureAccessService) : IBackofficeService
|
||||
{
|
||||
private static readonly BuiltinFeature[] BuiltinFeatures =
|
||||
[
|
||||
new(SaasFeatureCatalog.CoreBackoffice, "后台基础能力", "core", true, 0),
|
||||
new(SaasFeatureCatalog.PrivateQuestionBank, "私有题库", "question_bank", false, 10),
|
||||
new(SaasFeatureCatalog.Practice, "题库练习", "learning", false, 20),
|
||||
new(SaasFeatureCatalog.Assignment, "作业", "learning", false, 30),
|
||||
new(SaasFeatureCatalog.Exam, "考试", "learning", false, 40),
|
||||
new(SaasFeatureCatalog.Vocabulary, "词汇", "content", false, 50),
|
||||
new(SaasFeatureCatalog.Handbook, "知识手册", "content", false, 60),
|
||||
new(SaasFeatureCatalog.Video, "视频", "content", false, 70),
|
||||
new(SaasFeatureCatalog.Scoreline, "分数线", "content", false, 80),
|
||||
new(SaasFeatureCatalog.SiteContent, "站点运营内容", "marketing", false, 90),
|
||||
new(SaasFeatureCatalog.StudentManagement, "学生管理", "student", false, 100),
|
||||
new(SaasFeatureCatalog.StudentStore, "学生商城", "commerce", false, 110),
|
||||
new(SaasFeatureCatalog.Crm, "学生跟进", "crm", false, 120),
|
||||
new(SaasFeatureCatalog.ReferralCommission, "推广与分佣", "growth", false, 130),
|
||||
new(SaasFeatureCatalog.TeacherAi, "教师 AI 助手", "ai", false, 140)
|
||||
];
|
||||
|
||||
private static readonly BuiltinPermissionModule[] BuiltinPermissionModules =
|
||||
[
|
||||
new("tenant_dashboard", "租户总览", BackendPermissionArea.Tenant, null, 10),
|
||||
new("tenant_staff", "员工与角色", BackendPermissionArea.Tenant, null, 20),
|
||||
new("tenant_settings", "租户设置", BackendPermissionArea.Tenant, null, 30),
|
||||
new("tenant_provider", "外部服务", BackendPermissionArea.Tenant, null, 40),
|
||||
new("tenant_job", "后台任务", BackendPermissionArea.Tenant, null, 50),
|
||||
new("tenant_billing", "SaaS 账务", BackendPermissionArea.Tenant, null, 60),
|
||||
new("tenant_student", "学生管理", BackendPermissionArea.Tenant, SaasFeatureCatalog.StudentManagement, 100),
|
||||
new("tenant_question_bank", "私有题库", BackendPermissionArea.Tenant, SaasFeatureCatalog.PrivateQuestionBank, 110),
|
||||
new("tenant_vocabulary", "词汇", BackendPermissionArea.Tenant, SaasFeatureCatalog.Vocabulary, 120),
|
||||
new("tenant_handbook", "知识手册", BackendPermissionArea.Tenant, SaasFeatureCatalog.Handbook, 130),
|
||||
new("tenant_video", "视频", BackendPermissionArea.Tenant, SaasFeatureCatalog.Video, 140),
|
||||
new("tenant_scoreline", "分数线", BackendPermissionArea.Tenant, SaasFeatureCatalog.Scoreline, 150),
|
||||
new("tenant_site_content", "站点运营内容", BackendPermissionArea.Tenant, SaasFeatureCatalog.SiteContent, 160),
|
||||
new("tenant_commerce", "学生商城", BackendPermissionArea.Tenant, SaasFeatureCatalog.StudentStore, 170),
|
||||
new("tenant_crm", "学生跟进", BackendPermissionArea.Tenant, SaasFeatureCatalog.Crm, 180),
|
||||
new("tenant_commission", "推广分佣", BackendPermissionArea.Tenant, SaasFeatureCatalog.ReferralCommission, 190),
|
||||
new("platform_dashboard", "平台总览", BackendPermissionArea.Platform, null, 200),
|
||||
new("platform_tenant", "平台租户", BackendPermissionArea.Platform, null, 210),
|
||||
new("platform_staff", "平台员工", BackendPermissionArea.Platform, null, 220),
|
||||
new("platform_content", "公共题库", BackendPermissionArea.Platform, null, 230),
|
||||
new("platform_audit", "平台审计", BackendPermissionArea.Platform, null, 240),
|
||||
new("platform_billing", "平台 SaaS 商城", BackendPermissionArea.Platform, null, 250),
|
||||
new("commerce", "交易运营", BackendPermissionArea.Both, SaasFeatureCatalog.StudentStore, 300)
|
||||
];
|
||||
|
||||
private static readonly BuiltinPermission[] BuiltinPermissions =
|
||||
[
|
||||
new(BackendPermissions.TenantDashboardView, "租户总览", BackendPermissionArea.Tenant, "tenant_dashboard"),
|
||||
new(BackendPermissions.TenantStaffManage, "租户员工管理", BackendPermissionArea.Tenant, "tenant_staff"),
|
||||
new(BackendPermissions.TenantRoleManage, "租户角色权限管理", BackendPermissionArea.Tenant, "tenant_staff"),
|
||||
new(BackendPermissions.TenantStudentManage, "学生与班级管理", BackendPermissionArea.Tenant, "tenant_student"),
|
||||
new(BackendPermissions.TenantContentManage, "租户内容管理", BackendPermissionArea.Tenant, "tenant_content"),
|
||||
new(BackendPermissions.TenantContentManage, "租户题库管理", BackendPermissionArea.Tenant, "tenant_question_bank"),
|
||||
new(BackendPermissions.TenantVocabularyManage, "租户词汇管理", BackendPermissionArea.Tenant, "tenant_vocabulary"),
|
||||
new(BackendPermissions.TenantHandbookManage, "租户手册管理", BackendPermissionArea.Tenant, "tenant_handbook"),
|
||||
new(BackendPermissions.TenantVideoManage, "租户视频管理", BackendPermissionArea.Tenant, "tenant_video"),
|
||||
new(BackendPermissions.TenantScorelineManage, "租户分数线管理", BackendPermissionArea.Tenant, "tenant_scoreline"),
|
||||
new(BackendPermissions.TenantSiteContentManage, "租户运营内容管理", BackendPermissionArea.Tenant, "tenant_site_content"),
|
||||
new(BackendPermissions.TenantSettingsManage, "租户设置管理", BackendPermissionArea.Tenant, "tenant_settings"),
|
||||
new(BackendPermissions.TenantProviderManage, "租户外部服务配置", BackendPermissionArea.Tenant, "tenant_provider"),
|
||||
new(BackendPermissions.TenantCommerceOperate, "租户交易运营", BackendPermissionArea.Tenant, "tenant_commerce"),
|
||||
new(BackendPermissions.TenantCrmManage, "租户客户管理", BackendPermissionArea.Tenant, "tenant_crm"),
|
||||
new(BackendPermissions.TenantCommissionManage, "租户佣金管理", BackendPermissionArea.Tenant, "tenant_commission"),
|
||||
new(BackendPermissions.TenantJobManage, "租户任务管理", BackendPermissionArea.Tenant, "tenant_job"),
|
||||
new(BackendPermissions.TenantBillingManage, "租户 SaaS 账务", BackendPermissionArea.Tenant, "tenant_billing"),
|
||||
new(BackendPermissions.PlatformDashboardView, "平台总览", BackendPermissionArea.Platform, "platform_dashboard"),
|
||||
new(BackendPermissions.PlatformTenantManage, "平台租户管理", BackendPermissionArea.Platform, "platform_tenant"),
|
||||
new(BackendPermissions.PlatformStaffManage, "平台员工管理", BackendPermissionArea.Platform, "platform_staff"),
|
||||
new(BackendPermissions.PlatformRoleManage, "平台角色权限管理", BackendPermissionArea.Platform, "platform_staff"),
|
||||
new(BackendPermissions.PlatformQuestionBankManage, "平台公共题库运营", BackendPermissionArea.Platform, "platform_content"),
|
||||
new(BackendPermissions.PlatformAuditView, "平台审计查询", BackendPermissionArea.Platform, "platform_audit"),
|
||||
new(BackendPermissions.PlatformBillingNotification, "平台催缴通知", BackendPermissionArea.Platform, "platform_billing"),
|
||||
new(BackendPermissions.PlatformSaasCatalogManage, "SaaS 商品管理", BackendPermissionArea.Platform, "platform_billing"),
|
||||
new(BackendPermissions.PlatformSaasBillingManage, "SaaS 交易管理", BackendPermissionArea.Platform, "platform_billing"),
|
||||
new("commerce:refund:approve", "退款审核", BackendPermissionArea.Both, "commerce"),
|
||||
new("commerce:reconciliation:manage", "对账管理", BackendPermissionArea.Both, "commerce"),
|
||||
new("commerce:adjustment:manage", "调账管理", BackendPermissionArea.Both, "commerce")
|
||||
@@ -43,14 +99,21 @@ internal sealed class BackofficeService(
|
||||
new("tenant.dashboard", null, "租户总览", BackendPermissionArea.Tenant, "/tenant/dashboard", "tenant:dashboard:view", 10),
|
||||
new("tenant.staff", null, "员工与权限", BackendPermissionArea.Tenant, "/tenant/staff", "tenant:staff:manage", 20),
|
||||
new("tenant.students", null, "班级与学生", BackendPermissionArea.Tenant, "/tenant/students", "tenant:student:manage", 30),
|
||||
new("tenant.content", null, "内容管理", BackendPermissionArea.Tenant, "/tenant/content", "tenant:content:manage", 40),
|
||||
new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", "tenant:provider:manage", 50),
|
||||
new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", "tenant:commerce:operate", 60),
|
||||
new("tenant.question-bank", null, "私有题库", BackendPermissionArea.Tenant, "/tenant/question-bank", BackendPermissions.TenantContentManage, 40),
|
||||
new("tenant.vocabulary", null, "词汇", BackendPermissionArea.Tenant, "/tenant/vocabulary", BackendPermissions.TenantVocabularyManage, 50),
|
||||
new("tenant.handbook", null, "知识手册", BackendPermissionArea.Tenant, "/tenant/handbook", BackendPermissions.TenantHandbookManage, 60),
|
||||
new("tenant.video", null, "视频", BackendPermissionArea.Tenant, "/tenant/video", BackendPermissions.TenantVideoManage, 70),
|
||||
new("tenant.scoreline", null, "分数线", BackendPermissionArea.Tenant, "/tenant/scoreline", BackendPermissions.TenantScorelineManage, 80),
|
||||
new("tenant.site-content", null, "运营内容", BackendPermissionArea.Tenant, "/tenant/site-content", BackendPermissions.TenantSiteContentManage, 90),
|
||||
new("tenant.providers", null, "外部服务", BackendPermissionArea.Tenant, "/tenant/providers", BackendPermissions.TenantProviderManage, 100),
|
||||
new("tenant.commerce", null, "交易运营", BackendPermissionArea.Tenant, "/tenant/commerce", BackendPermissions.TenantCommerceOperate, 110),
|
||||
new("tenant.billing", null, "SaaS 账务", BackendPermissionArea.Tenant, "/tenant/billing", BackendPermissions.TenantBillingManage, 120),
|
||||
new("platform.dashboard", null, "平台总览", BackendPermissionArea.Platform, "/platform/dashboard", "platform:dashboard:view", 10),
|
||||
new("platform.tenants", null, "租户管理", BackendPermissionArea.Platform, "/platform/tenants", "platform:tenant:manage", 20),
|
||||
new("platform.staff", null, "平台员工", BackendPermissionArea.Platform, "/platform/staff", "platform:staff:manage", 30),
|
||||
new("platform.content", null, "公共题库", BackendPermissionArea.Platform, "/platform/question-banks", "platform:question-bank:manage", 40),
|
||||
new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50)
|
||||
new("platform.audit", null, "平台审计", BackendPermissionArea.Platform, "/platform/audit", "platform:audit:view", 50),
|
||||
new("platform.saas", null, "SaaS 商城", BackendPermissionArea.Platform, "/platform/saas", "platform:saas-catalog:manage", 60)
|
||||
];
|
||||
|
||||
public async Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
|
||||
@@ -73,7 +136,10 @@ internal sealed class BackofficeService(
|
||||
BackendPermissionArea.Tenant,
|
||||
permissionCodes,
|
||||
cancellationToken);
|
||||
return new BackofficeUiBootstrap(permissionCodes, menus);
|
||||
var features = await featureAccessService.GetEnabledFeaturesAsync(
|
||||
access.TenantId.Value, FeatureAccessOperation.Read, cancellationToken);
|
||||
var quotas = await featureAccessService.GetQuotaSummaryAsync(access.TenantId.Value, cancellationToken);
|
||||
return new BackofficeUiBootstrap(permissionCodes, menus, features.Order(StringComparer.Ordinal).ToArray(), quotas);
|
||||
}
|
||||
|
||||
public async Task<BackofficeUiBootstrap> GetPlatformUiBootstrapAsync(
|
||||
@@ -91,7 +157,7 @@ internal sealed class BackofficeService(
|
||||
BackendPermissionArea.Platform,
|
||||
permissionCodes,
|
||||
cancellationToken);
|
||||
return new BackofficeUiBootstrap(permissionCodes, menus);
|
||||
return new BackofficeUiBootstrap(permissionCodes, menus, [], []);
|
||||
}
|
||||
|
||||
public async Task<BackofficeBootstrap> GetTenantBootstrapAsync(
|
||||
@@ -103,7 +169,7 @@ internal sealed class BackofficeService(
|
||||
var roles = await LoadTenantRolesAsync(tenantId, cancellationToken);
|
||||
var permissions = await dbContext.BackendPermissions.AsNoTracking()
|
||||
.Where(item => item.Area == BackendPermissionArea.Tenant || item.Area == BackendPermissionArea.Both)
|
||||
.OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
|
||||
.OrderBy(item => item.PermissionModuleCode).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var enabledPermissionCodes = await FilterTenantPermissionCodesAsync(
|
||||
tenantId,
|
||||
@@ -132,7 +198,7 @@ internal sealed class BackofficeService(
|
||||
var roles = await LoadPlatformRolesAsync(cancellationToken);
|
||||
var permissions = await dbContext.BackendPermissions.AsNoTracking()
|
||||
.Where(item => item.Area == BackendPermissionArea.Platform || item.Area == BackendPermissionArea.Both)
|
||||
.OrderBy(item => item.Module).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
|
||||
.OrderBy(item => item.PermissionModuleCode).ThenBy(item => item.SortOrder).ThenBy(item => item.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var menus = await dbContext.BackendMenus.AsNoTracking()
|
||||
.Where(item => item.IsActive && item.Area == BackendPermissionArea.Platform)
|
||||
@@ -313,6 +379,39 @@ internal sealed class BackofficeService(
|
||||
|
||||
private async Task EnsureCatalogAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var feature in BuiltinFeatures)
|
||||
{
|
||||
if (!await dbContext.SaasFeatures.AnyAsync(item => item.Code == feature.Code, cancellationToken))
|
||||
{
|
||||
dbContext.SaasFeatures.Add(new SaasFeature
|
||||
{
|
||||
Code = feature.Code,
|
||||
Name = feature.Name,
|
||||
Category = feature.Category,
|
||||
IsCore = feature.IsCore,
|
||||
Status = SaasFeatureStatus.Active,
|
||||
SortOrder = feature.SortOrder
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var module in BuiltinPermissionModules)
|
||||
{
|
||||
if (!await dbContext.PermissionModules.AnyAsync(item => item.Code == module.Code, cancellationToken))
|
||||
{
|
||||
dbContext.PermissionModules.Add(new PermissionModule
|
||||
{
|
||||
Code = module.Code,
|
||||
Name = module.Name,
|
||||
Area = module.Area,
|
||||
RequiredFeatureCode = module.RequiredFeatureCode,
|
||||
SortOrder = module.SortOrder
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
foreach (var permission in BuiltinPermissions)
|
||||
{
|
||||
if (await dbContext.BackendPermissions.AnyAsync(item => item.Code == permission.Code, cancellationToken))
|
||||
@@ -325,7 +424,7 @@ internal sealed class BackofficeService(
|
||||
Code = permission.Code,
|
||||
Name = permission.Name,
|
||||
Area = permission.Area,
|
||||
Module = permission.Module,
|
||||
PermissionModuleCode = permission.PermissionModuleCode,
|
||||
IsSystem = true
|
||||
});
|
||||
}
|
||||
@@ -363,18 +462,13 @@ internal sealed class BackofficeService(
|
||||
var normalizedPermissions = NormalizeCodes(permissionCodes);
|
||||
var normalizedMenus = NormalizeCodes(menuCodes);
|
||||
await ValidatePermissionCodesAsync(normalizedPermissions, BackendPermissionArea.Tenant, cancellationToken);
|
||||
foreach (var permissionCode in normalizedPermissions)
|
||||
var allowedPermissions = await featureAccessService.FilterPermissionCodesAsync(
|
||||
tenantId, normalizedPermissions, FeatureAccessOperation.Write, cancellationToken);
|
||||
if (allowedPermissions.Count != normalizedPermissions.Length)
|
||||
{
|
||||
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||
tenantId,
|
||||
ResolveModuleCode(permissionCode),
|
||||
CapabilityOperation.Write,
|
||||
cancellationToken))
|
||||
{
|
||||
throw new BackofficeException(
|
||||
"One or more permissions belong to a module unavailable to this tenant.",
|
||||
"capability_not_available");
|
||||
}
|
||||
throw new BackofficeException(
|
||||
"One or more permissions belong to a feature unavailable to this tenant.",
|
||||
"feature_not_available");
|
||||
}
|
||||
await ValidateMenuCodesAsync(normalizedMenus, BackendPermissionArea.Tenant, cancellationToken);
|
||||
await dbContext.TenantBackendRolePermissions.Where(item => item.TenantId == tenantId && item.RoleId == roleId).ExecuteDeleteAsync(cancellationToken);
|
||||
@@ -433,25 +527,12 @@ internal sealed class BackofficeService(
|
||||
CapabilityOperation operation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var enabled = new List<string>();
|
||||
foreach (var permissionCode in permissionCodes.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal))
|
||||
{
|
||||
if (await capabilityAccessEvaluator.IsAllowedAsync(
|
||||
tenantId,
|
||||
ResolveModuleCode(permissionCode),
|
||||
operation,
|
||||
cancellationToken))
|
||||
{
|
||||
enabled.Add(permissionCode);
|
||||
}
|
||||
}
|
||||
return enabled.ToArray();
|
||||
}
|
||||
|
||||
private static string ResolveModuleCode(string permissionCode)
|
||||
{
|
||||
var parts = permissionCode.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return parts.Length >= 2 ? parts[1].ToLowerInvariant() : permissionCode.ToLowerInvariant();
|
||||
var featureOperation = operation == CapabilityOperation.Read
|
||||
? FeatureAccessOperation.Read
|
||||
: FeatureAccessOperation.Write;
|
||||
var enabled = await featureAccessService.FilterPermissionCodesAsync(
|
||||
tenantId, permissionCodes, featureOperation, cancellationToken);
|
||||
return enabled.Order(StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private async Task ValidateMenuCodesAsync(string[] codes, BackendPermissionArea area, CancellationToken cancellationToken)
|
||||
@@ -544,7 +625,7 @@ internal sealed class BackofficeService(
|
||||
|
||||
private static BackofficePermissionItem ToPermissionItem(BackendPermission item)
|
||||
{
|
||||
return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.Module, item.Description, item.SortOrder);
|
||||
return new BackofficePermissionItem(item.Id, item.Code, item.Name, item.Area, item.PermissionModuleCode, item.Description, item.SortOrder);
|
||||
}
|
||||
|
||||
private static BackofficeMenuItem ToMenuItem(BackendMenu item)
|
||||
@@ -579,7 +660,9 @@ internal sealed class BackofficeService(
|
||||
menus.Where(item => item.RoleId == role.Id).Select(item => item.MenuCode).Order().ToArray());
|
||||
}
|
||||
|
||||
private sealed record BuiltinPermission(string Code, string Name, BackendPermissionArea Area, string Module);
|
||||
private sealed record BuiltinPermission(string Code, string Name, BackendPermissionArea Area, string PermissionModuleCode);
|
||||
private sealed record BuiltinFeature(string Code, string Name, string Category, bool IsCore, int SortOrder);
|
||||
private sealed record BuiltinPermissionModule(string Code, string Name, BackendPermissionArea Area, string? RequiredFeatureCode, int SortOrder);
|
||||
private sealed record BuiltinMenu(string Code, string? ParentCode, string Title, BackendPermissionArea Area, string Path, string PermissionCode, int SortOrder);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using Npgsql;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Bootstrap;
|
||||
@@ -110,6 +111,22 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
};
|
||||
dbContext.Users.Add(user);
|
||||
dbContext.PlatformBackendRoles.Add(role);
|
||||
var platformModuleCodes = permissionCodes
|
||||
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var existingModuleCodes = dbContext.PermissionModules
|
||||
.Where(module => platformModuleCodes.Contains(module.Code))
|
||||
.Select(module => module.Code)
|
||||
.ToArray();
|
||||
dbContext.PermissionModules.AddRange(platformModuleCodes
|
||||
.Except(existingModuleCodes, StringComparer.Ordinal)
|
||||
.Select(code => new PermissionModule
|
||||
{
|
||||
Code = code,
|
||||
Name = code,
|
||||
Area = BackendPermissionArea.Platform
|
||||
}));
|
||||
dbContext.BackendPermissions.AddRange(
|
||||
permissionCodes
|
||||
.Where(code => !existingPermissionCodes.Contains(code))
|
||||
@@ -118,7 +135,7 @@ public static class DevelopmentPlatformAdminSeeder
|
||||
Code = code,
|
||||
Name = code,
|
||||
Area = BackendPermissionArea.Platform,
|
||||
Module = "platform",
|
||||
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(code),
|
||||
Description = "Built-in platform permission.",
|
||||
IsSystem = true
|
||||
}));
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Bootstrap;
|
||||
@@ -100,6 +101,22 @@ public sealed class PlatformAdminBootstrapper(
|
||||
dbContext.PlatformBackendRoles.Add(role);
|
||||
|
||||
var platformPermissionCodes = BackendPermissions.Platform.ToArray();
|
||||
var platformModuleCodes = platformPermissionCodes
|
||||
.Select(PermissionModuleCatalog.ResolvePermissionModuleCode)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var existingModuleCodes = await dbContext.PermissionModules
|
||||
.Where(module => platformModuleCodes.Contains(module.Code))
|
||||
.Select(module => module.Code)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
dbContext.PermissionModules.AddRange(platformModuleCodes
|
||||
.Except(existingModuleCodes, StringComparer.Ordinal)
|
||||
.Select(code => new PermissionModule
|
||||
{
|
||||
Code = code,
|
||||
Name = code,
|
||||
Area = BackendPermissionArea.Platform
|
||||
}));
|
||||
var existingPermissionCodes = await dbContext.BackendPermissions
|
||||
.Where(permission => platformPermissionCodes.Contains(permission.Code))
|
||||
.Select(permission => permission.Code)
|
||||
@@ -111,7 +128,7 @@ public sealed class PlatformAdminBootstrapper(
|
||||
Code = permissionCode,
|
||||
Name = permissionCode,
|
||||
Area = BackendPermissionArea.Platform,
|
||||
Module = "platform",
|
||||
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(permissionCode),
|
||||
Description = "Built-in platform permission.",
|
||||
IsSystem = true
|
||||
});
|
||||
|
||||
@@ -20,8 +20,19 @@ namespace Tiku.Infrastructure.Content;
|
||||
public sealed class DirectContentService(
|
||||
TikuDbContext dbContext,
|
||||
IQuestionReferenceService questionReferenceService,
|
||||
ICurrentAccessContext currentAccessContext) : IDirectContentService
|
||||
ICurrentAccessContext currentAccessContext,
|
||||
IFeatureAccessService featureAccessService) : IDirectContentService
|
||||
{
|
||||
private static readonly string[] ContentPermissions =
|
||||
[
|
||||
BackendPermissions.TenantContentManage,
|
||||
BackendPermissions.TenantVocabularyManage,
|
||||
BackendPermissions.TenantHandbookManage,
|
||||
BackendPermissions.TenantVideoManage,
|
||||
BackendPermissions.TenantScorelineManage,
|
||||
BackendPermissions.TenantSiteContentManage,
|
||||
BackendPermissions.TenantJobManage
|
||||
];
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 1000;
|
||||
private static readonly Regex ScorelineFieldKeyRegex = new("^[A-Za-z][A-Za-z0-9_]{0,63}$", RegexOptions.Compiled);
|
||||
@@ -50,6 +61,13 @@ public sealed class DirectContentService(
|
||||
TenantId = actor.TenantId
|
||||
};
|
||||
ApplyQuestion(question, command);
|
||||
if (question.Status != QuestionStatus.Archived)
|
||||
{
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.PrivateQuestionCount,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
dbContext.Questions.Add(question);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -85,7 +103,19 @@ public sealed class DirectContentService(
|
||||
}
|
||||
|
||||
await AssertQuestionReferencesAsync(actor.TenantId, command, cancellationToken);
|
||||
await using var transaction = dbContext.Database.CurrentTransaction is null
|
||||
? await dbContext.Database.BeginTransactionAsync(cancellationToken)
|
||||
: null;
|
||||
var wasCounted = question.Status != QuestionStatus.Archived;
|
||||
ApplyQuestion(question, command);
|
||||
var isCounted = question.Status != QuestionStatus.Archived;
|
||||
if (!wasCounted && isCounted)
|
||||
{
|
||||
await featureAccessService.ConsumeQuotaIfConfiguredAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.PrivateQuestionCount,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
QuestionVersion? version;
|
||||
if (command.CreateVersion || !question.CurrentVersionId.HasValue)
|
||||
{
|
||||
@@ -120,6 +150,18 @@ public sealed class DirectContentService(
|
||||
await SyncPrimaryCollectionItemAsync(actor, question, cancellationToken);
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (wasCounted && !isCounted)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(
|
||||
actor.TenantId,
|
||||
SaasQuotaMetricCatalog.PrivateQuestionCount,
|
||||
1,
|
||||
cancellationToken);
|
||||
}
|
||||
if (transaction is not null)
|
||||
{
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
return new ContentManagementResult<QuestionManagementItem>(ToQuestionItem(question, version));
|
||||
}
|
||||
|
||||
@@ -1815,7 +1857,7 @@ public sealed class DirectContentService(
|
||||
if (!access.IsCurrentTenantMember ||
|
||||
access.UserId != actor.UserId ||
|
||||
access.TenantId != actor.TenantId ||
|
||||
!access.HasTenantPermission(BackendPermissions.TenantContentManage))
|
||||
!ContentPermissions.Any(access.HasTenantPermission))
|
||||
{
|
||||
throw new ContentManagementException("Tenant content access was denied.", "content_access_denied");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Profile;
|
||||
using Tiku.Application.Points;
|
||||
using Tiku.Application.PlatformAdmin;
|
||||
using Tiku.Application.PlatformBilling;
|
||||
using Tiku.Application.Scoreline;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.StudyContent;
|
||||
@@ -36,6 +37,7 @@ using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.Profile;
|
||||
using Tiku.Infrastructure.Points;
|
||||
using Tiku.Infrastructure.PlatformAdmin;
|
||||
using Tiku.Infrastructure.PlatformBilling;
|
||||
using Tiku.Infrastructure.QuestionBanks;
|
||||
using Tiku.Infrastructure.Scoreline;
|
||||
using Tiku.Infrastructure.Security;
|
||||
@@ -125,8 +127,19 @@ public static class DependencyInjection
|
||||
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
|
||||
services.AddScoped<IBackofficeService, BackofficeService>();
|
||||
services.AddScoped<IPlatformAdminService, PlatformAdminService>();
|
||||
services.AddScoped<ISaasCatalogAdminService, SaasCatalogAdminService>();
|
||||
services.AddScoped<ITenantBillingService, TenantBillingService>();
|
||||
services.AddScoped<IPlatformBillingAdminService, PlatformBillingAdminService>();
|
||||
services.AddScoped<IPlatformBillingPaymentGateway, PlatformBillingPaymentGateway>();
|
||||
services.AddScoped<IPlatformBillingNotificationService, PlatformBillingNotificationService>();
|
||||
services.AddScoped<IPlatformBillingSettlementService, PlatformBillingSettlementService>();
|
||||
services.AddScoped<ISaasSubscriptionLifecycleService, SaasSubscriptionLifecycleService>();
|
||||
services.AddOptions<SaasSubscriptionLifecycleOptions>();
|
||||
services.AddScoped<ITenantOnboardingService, TenantOnboardingService>();
|
||||
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
|
||||
services.AddScoped<ICapabilityAccessEvaluator, CapabilityAccessEvaluator>();
|
||||
services.AddScoped<IFeatureAccessService, FeatureAccessService>();
|
||||
services.AddScoped<IFeatureUsageReconciliationService, FeatureUsageReconciliationService>();
|
||||
services.AddOptions<FeatureUsageReconciliationOptions>();
|
||||
services.AddScoped<IOperationAuditService, OperationAuditService>();
|
||||
services.AddSingleton<IBackgroundJobDispatcher, NullBackgroundJobDispatcher>();
|
||||
services.AddScoped<IBackgroundJobService, BackgroundJobService>();
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Tiku.Infrastructure.Jobs;
|
||||
internal sealed class BackgroundJobService(
|
||||
TikuDbContext dbContext,
|
||||
ITenantExecutionScope tenantExecutionScope,
|
||||
ICapabilityAccessEvaluator capabilityAccessEvaluator,
|
||||
IFeatureAccessService featureAccessService,
|
||||
IBackgroundJobDispatcher backgroundJobDispatcher) : IBackgroundJobService
|
||||
{
|
||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromMinutes(5);
|
||||
@@ -26,13 +26,29 @@ internal sealed class BackgroundJobService(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedJobType = NormalizeJobType(command.JobType);
|
||||
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||
if (!(await featureAccessService.EvaluateAsync(
|
||||
command.TenantId,
|
||||
ResolveCapabilityModule(normalizedJobType),
|
||||
CapabilityOperation.Write,
|
||||
cancellationToken))
|
||||
ResolveRequiredFeature(normalizedJobType, command.Payload),
|
||||
FeatureAccessOperation.Write,
|
||||
cancellationToken)).Allowed)
|
||||
{
|
||||
throw new InvalidOperationException("Tenant capability does not allow this background job.");
|
||||
throw new InvalidOperationException("Tenant feature entitlement does not allow this background job.");
|
||||
}
|
||||
var quotaMetric = ResolveQuotaMetric(normalizedJobType);
|
||||
var quotaConsumed = false;
|
||||
if (quotaMetric is not null)
|
||||
{
|
||||
var quota = (await featureAccessService.GetQuotaSummaryAsync(command.TenantId, cancellationToken))
|
||||
.SingleOrDefault(value => value.MetricCode == quotaMetric);
|
||||
if (quota is not null)
|
||||
{
|
||||
quotaConsumed = await featureAccessService.TryConsumeQuotaAsync(
|
||||
command.TenantId, quotaMetric, 1, cancellationToken);
|
||||
if (!quotaConsumed)
|
||||
{
|
||||
throw new FeatureAccessException("The background job quota has been exhausted.", "feature_quota_exhausted");
|
||||
}
|
||||
}
|
||||
}
|
||||
var job = new BackgroundJob
|
||||
{
|
||||
@@ -43,6 +59,18 @@ internal sealed class BackgroundJobService(
|
||||
MaxRetries = Math.Clamp(command.MaxRetries, 0, 20)
|
||||
};
|
||||
dbContext.BackgroundJobs.Add(job);
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (quotaConsumed && quotaMetric is not null)
|
||||
{
|
||||
await featureAccessService.ReleaseQuotaAsync(command.TenantId, quotaMetric, 1, CancellationToken.None);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
if (job.RunAfter is null && backgroundJobDispatcher.IsEnabled)
|
||||
{
|
||||
await backgroundJobDispatcher.DispatchAsync(
|
||||
@@ -52,10 +80,16 @@ internal sealed class BackgroundJobService(
|
||||
job.Id.ToString("N"),
|
||||
cancellationToken);
|
||||
}
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return ToItem(job);
|
||||
}
|
||||
|
||||
private static string? ResolveQuotaMetric(string jobType) => jobType switch
|
||||
{
|
||||
"content_import" => SaasQuotaMetricCatalog.ImportCount,
|
||||
"content_export" => SaasQuotaMetricCatalog.ExportCount,
|
||||
_ => null
|
||||
};
|
||||
|
||||
public async Task<int> ProcessPendingAsync(
|
||||
string workerId,
|
||||
int batchSize,
|
||||
@@ -118,15 +152,15 @@ internal sealed class BackgroundJobService(
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!await capabilityAccessEvaluator.IsAllowedAsync(
|
||||
if (!(await featureAccessService.EvaluateAsync(
|
||||
job.TenantId,
|
||||
ResolveCapabilityModule(job.JobType),
|
||||
CapabilityOperation.Write,
|
||||
cancellationToken))
|
||||
ResolveRequiredFeature(job.JobType, job.Payload),
|
||||
FeatureAccessOperation.Write,
|
||||
cancellationToken)).Allowed)
|
||||
{
|
||||
job.Status = BackgroundJobStatus.Failed;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
job.LastError = "Tenant capability was revoked before job execution.";
|
||||
job.LastError = "Tenant feature entitlement was revoked before job execution.";
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
@@ -205,7 +239,7 @@ internal sealed class BackgroundJobService(
|
||||
"content_export" => await ProcessContentExportAsync(scopedDbContext, job, cancellationToken),
|
||||
"content_import" => await ProcessContentImportAsync(scopedProvider, job, cancellationToken),
|
||||
"asset_security_scan" => throw new NotSupportedException("asset_security_scan requires a configured scanner provider before it can write scan results."),
|
||||
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(scopedDbContext, job, cancellationToken),
|
||||
"statistics_aggregation" => await ProcessStatisticsAggregationAsync(scopedProvider, scopedDbContext, job, cancellationToken),
|
||||
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(scopedDbContext, job, cancellationToken),
|
||||
"tenant_domain_recheck" => await ProcessTenantDomainRecheckAsync(scopedProvider, cancellationToken),
|
||||
_ => throw new InvalidOperationException($"Unsupported background job type '{job.JobType}'.")
|
||||
@@ -380,6 +414,7 @@ internal sealed class BackgroundJobService(
|
||||
}
|
||||
|
||||
private async Task<JsonElement> ProcessStatisticsAggregationAsync(
|
||||
IServiceProvider scopedProvider,
|
||||
TikuDbContext scopedDbContext,
|
||||
BackgroundJob job,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -398,12 +433,22 @@ internal sealed class BackgroundJobService(
|
||||
item.Status == OrderStatus.PartiallyRefunded ||
|
||||
item.Status == OrderStatus.Refunded))
|
||||
.SumAsync(item => item.AmountCents - item.RefundedAmountCents, cancellationToken);
|
||||
var quotaUsage = await scopedProvider.GetRequiredService<IFeatureUsageReconciliationService>()
|
||||
.ReconcileTenantAsync(
|
||||
new ReconcileFeatureUsageRequest(
|
||||
job.TenantId,
|
||||
SystemScopeCallerType.Worker,
|
||||
nameof(BackgroundJobService),
|
||||
"Reconcile tenant current feature usage during statistics aggregation",
|
||||
$"feature-usage-{job.Id:N}"),
|
||||
cancellationToken);
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
since,
|
||||
activeLearnerCount,
|
||||
paidOrderCount,
|
||||
revenueCents
|
||||
revenueCents,
|
||||
quotaUsage
|
||||
});
|
||||
}
|
||||
|
||||
@@ -412,13 +457,14 @@ internal sealed class BackgroundJobService(
|
||||
return jobType.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ResolveCapabilityModule(string jobType) => jobType switch
|
||||
private static string ResolveRequiredFeature(string jobType, JsonElement payload) => jobType switch
|
||||
{
|
||||
"content_export" or "content_import" or "asset_security_scan" => "content",
|
||||
"statistics_aggregation" => "dashboard",
|
||||
"commerce_reconciliation" => "commerce",
|
||||
"tenant_domain_recheck" => "settings",
|
||||
_ => "job"
|
||||
"content_import" => SaasFeatureCatalog.ResolveContentImportFeature(GetJsonString(payload, "importType"))
|
||||
?? throw new InvalidOperationException("Background content import type is not supported."),
|
||||
"content_export" or "asset_security_scan" => SaasFeatureCatalog.PrivateQuestionBank,
|
||||
"commerce_reconciliation" => SaasFeatureCatalog.StudentStore,
|
||||
"statistics_aggregation" or "tenant_domain_recheck" => SaasFeatureCatalog.CoreBackoffice,
|
||||
_ => SaasFeatureCatalog.CoreBackoffice
|
||||
};
|
||||
|
||||
private static string NormalizeProvider(string? provider)
|
||||
|
||||
@@ -299,35 +299,6 @@ internal sealed class CouponRedemptionConfiguration : IEntityTypeConfiguration<C
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantSubscriptionConfiguration : IEntityTypeConfiguration<TenantSubscription>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantSubscription> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_subscriptions");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.PlanCode).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.BillingCycle).HasMaxLength(50);
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.ExpiresAt });
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantUsageRecordConfiguration : IEntityTypeConfiguration<TenantUsageRecord>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantUsageRecord> builder)
|
||||
{
|
||||
builder.ConfigureEntity("tenant_usage_records");
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
|
||||
builder.Property(entity => entity.MetricKey).HasMaxLength(100);
|
||||
builder.Property(entity => entity.MetricValue).HasPrecision(18, 4);
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.MetricKey, entity.PeriodStart, entity.PeriodEnd });
|
||||
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class CommerceRefundRequestConfiguration : IEntityTypeConfiguration<CommerceRefundRequest>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CommerceRefundRequest> builder)
|
||||
|
||||
@@ -4,6 +4,7 @@ using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
@@ -89,10 +90,14 @@ internal sealed class BackendPermissionConfiguration : IEntityTypeConfiguration<
|
||||
builder.Property(entity => entity.Code).HasMaxLength(160);
|
||||
builder.Property(entity => entity.Name).HasMaxLength(200);
|
||||
builder.Property(entity => entity.Area).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Module).HasMaxLength(100);
|
||||
builder.Property(entity => entity.PermissionModuleCode).HasMaxLength(120);
|
||||
builder.Property(entity => entity.Description).HasMaxLength(1000);
|
||||
builder.HasIndex(entity => entity.Code).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.Area, entity.Module, entity.SortOrder });
|
||||
builder.HasIndex(entity => new { entity.Area, entity.PermissionModuleCode, entity.SortOrder });
|
||||
builder.HasOne<PermissionModule>().WithMany()
|
||||
.HasPrincipalKey(entity => entity.Code)
|
||||
.HasForeignKey(entity => entity.PermissionModuleCode)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,76 +7,6 @@ using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Configurations;
|
||||
|
||||
internal sealed class PlatformSaasPlanConfiguration : IEntityTypeConfiguration<PlatformSaasPlan>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformSaasPlan> builder)
|
||||
{
|
||||
builder.ConfigureEntity("platform_saas_plans");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Code).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Name).HasMaxLength(200);
|
||||
builder.Property(entity => entity.BillingCycle).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Currency).HasMaxLength(10);
|
||||
builder.Property(entity => entity.IncludedQuotas).IsJson("{}");
|
||||
builder.Property(entity => entity.OveragePrices).IsJson("{}");
|
||||
builder.Property(entity => entity.FeatureFlags).IsJson("{}");
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.HasIndex(entity => entity.Code).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.Status, entity.SortOrder });
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_platform_saas_plans_amount", "base_amount_cents >= 0"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ProductModuleConfiguration : IEntityTypeConfiguration<ProductModule>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ProductModule> builder)
|
||||
{
|
||||
builder.ConfigureEntity("product_modules");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Code).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Name).HasMaxLength(200);
|
||||
builder.Property(entity => entity.Description).HasMaxLength(1000);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.HasIndex(entity => entity.Code).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlanModuleEntitlementConfiguration : IEntityTypeConfiguration<PlanModuleEntitlement>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlanModuleEntitlement> builder)
|
||||
{
|
||||
builder.ConfigureEntity("plan_module_entitlements");
|
||||
builder.Property(entity => entity.PlanCode).HasMaxLength(100);
|
||||
builder.Property(entity => entity.ModuleCode).HasMaxLength(100);
|
||||
builder.HasIndex(entity => new { entity.PlanCode, entity.ModuleCode }).IsUnique();
|
||||
builder.HasOne<PlatformSaasPlan>().WithMany()
|
||||
.HasPrincipalKey(entity => entity.Code)
|
||||
.HasForeignKey(entity => entity.PlanCode)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<ProductModule>().WithMany()
|
||||
.HasPrincipalKey(entity => entity.Code)
|
||||
.HasForeignKey(entity => entity.ModuleCode)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantModuleOverrideConfiguration : IEntityTypeConfiguration<TenantModuleOverride>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantModuleOverride> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_module_overrides");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.ModuleCode).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Mode).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Reason).HasMaxLength(1000);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.ModuleCode }).IsUnique();
|
||||
builder.HasOne<ProductModule>().WithMany()
|
||||
.HasPrincipalKey(entity => entity.Code)
|
||||
.HasForeignKey(entity => entity.ModuleCode)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantBillingProfileConfiguration : IEntityTypeConfiguration<TenantBillingProfile>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantBillingProfile> builder)
|
||||
@@ -98,84 +28,11 @@ internal sealed class TenantBillingProfileConfiguration : IEntityTypeConfigurati
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantInvoiceConfiguration : IEntityTypeConfiguration<TenantInvoice>
|
||||
internal sealed class PlatformBillingInvoiceReminderConfiguration : IEntityTypeConfiguration<PlatformBillingInvoiceReminder>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantInvoice> builder)
|
||||
public void Configure(EntityTypeBuilder<PlatformBillingInvoiceReminder> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_invoices");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.InvoiceNo).HasMaxLength(100);
|
||||
builder.Property(entity => entity.InvoiceType).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Currency).HasMaxLength(10);
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.InvoiceNo }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.DueDate });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.BillingPeriodStart, entity.BillingPeriodEnd })
|
||||
.IsUnique()
|
||||
.HasFilter("invoice_type = 'usage_overage' and status <> 'void' and metadata->>'source' = 'usage_overage_auto'");
|
||||
builder.ToTable(table =>
|
||||
{
|
||||
table.HasCheckConstraint("ck_tenant_invoices_amounts", "subtotal_cents >= 0 and discount_cents >= 0 and tax_cents >= 0 and total_cents >= 0 and paid_cents >= 0 and balance_cents >= 0");
|
||||
table.HasCheckConstraint("ck_tenant_invoices_period", "billing_period_end is null or billing_period_start is null or billing_period_end >= billing_period_start");
|
||||
});
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.CreatedBy).OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantInvoiceItemConfiguration : IEntityTypeConfiguration<TenantInvoiceItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantInvoiceItem> builder)
|
||||
{
|
||||
builder.ConfigureEntity("tenant_invoice_items");
|
||||
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
|
||||
builder.Property(entity => entity.ItemType).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Description).HasMaxLength(500);
|
||||
builder.Property(entity => entity.Quantity).HasPrecision(12, 2);
|
||||
builder.Property(entity => entity.Metadata).IsJson("{}");
|
||||
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(entity => entity.InvoiceId);
|
||||
builder.ToTable(table =>
|
||||
{
|
||||
table.HasCheckConstraint("ck_tenant_invoice_items_quantity", "quantity > 0");
|
||||
table.HasCheckConstraint("ck_tenant_invoice_items_amounts", "unit_amount_cents >= 0 and amount_cents >= 0");
|
||||
});
|
||||
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<TenantInvoice>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.InvoiceId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantInvoicePaymentConfiguration : IEntityTypeConfiguration<TenantInvoicePayment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantInvoicePayment> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_invoice_payments");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.PaymentNo).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Provider).HasMaxLength(50);
|
||||
builder.Property(entity => entity.Method).HasMaxLength(50);
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.ProviderTradeNo).HasMaxLength(200);
|
||||
builder.Property(entity => entity.RawPayload).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.PaymentNo }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.InvoiceId, entity.Status });
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_tenant_invoice_payments_amount", "amount_cents >= 0"));
|
||||
builder.HasOne<TenantInvoice>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.InvoiceId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<User>().WithMany().HasForeignKey(entity => entity.ReceivedBy).OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantInvoiceReminderConfiguration : IEntityTypeConfiguration<TenantInvoiceReminder>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantInvoiceReminder> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_invoice_reminders");
|
||||
builder.ConfigureTenantEntity("platform_billing_invoice_reminders");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.ReminderType).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Channel).HasSnakeCaseEnum();
|
||||
@@ -186,10 +43,10 @@ internal sealed class TenantInvoiceReminderConfiguration : IEntityTypeConfigurat
|
||||
builder.HasIndex(entity => new { entity.InvoiceId, entity.ReminderDate });
|
||||
builder.ToTable(table =>
|
||||
{
|
||||
table.HasCheckConstraint("ck_tenant_invoice_reminders_level", "reminder_level between 1 and 20");
|
||||
table.HasCheckConstraint("ck_tenant_invoice_reminders_balance", "balance_cents_snapshot >= 0");
|
||||
table.HasCheckConstraint("ck_platform_billing_invoice_reminders_level", "reminder_level between 1 and 20");
|
||||
table.HasCheckConstraint("ck_platform_billing_invoice_reminders_balance", "balance_cents_snapshot >= 0");
|
||||
});
|
||||
builder.HasOne<TenantInvoice>().WithMany()
|
||||
builder.HasOne<PlatformBillingInvoice>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.InvoiceId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
@@ -243,11 +100,11 @@ internal sealed class PlatformAuditAlertConfiguration : IEntityTypeConfiguration
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlatformDunningNotificationChannelConfiguration : IEntityTypeConfiguration<PlatformDunningNotificationChannel>
|
||||
internal sealed class PlatformBillingDunningNotificationChannelConfiguration : IEntityTypeConfiguration<PlatformBillingDunningNotificationChannel>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformDunningNotificationChannel> builder)
|
||||
public void Configure(EntityTypeBuilder<PlatformBillingDunningNotificationChannel> builder)
|
||||
{
|
||||
builder.ConfigureEntity("platform_dunning_notification_channels");
|
||||
builder.ConfigureEntity("platform_billing_dunning_notification_channels");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.ChannelCode).HasMaxLength(100);
|
||||
builder.Property(entity => entity.Name).HasMaxLength(200);
|
||||
@@ -262,17 +119,17 @@ internal sealed class PlatformDunningNotificationChannelConfiguration : IEntityT
|
||||
builder.HasIndex(entity => new { entity.Enabled, entity.MinReminderLevel, entity.ChannelCode });
|
||||
builder.ToTable(table =>
|
||||
{
|
||||
table.HasCheckConstraint("ck_platform_dunning_channels_level", "min_reminder_level between 1 and 20");
|
||||
table.HasCheckConstraint("ck_platform_dunning_channels_timeout", "timeout_seconds between 1 and 60");
|
||||
table.HasCheckConstraint("ck_platform_billing_dunning_channels_level", "min_reminder_level between 1 and 20");
|
||||
table.HasCheckConstraint("ck_platform_billing_dunning_channels_timeout", "timeout_seconds between 1 and 60");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlatformDunningNotificationEventConfiguration : IEntityTypeConfiguration<PlatformDunningNotificationEvent>
|
||||
internal sealed class PlatformBillingDunningNotificationEventConfiguration : IEntityTypeConfiguration<PlatformBillingDunningNotificationEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformDunningNotificationEvent> builder)
|
||||
public void Configure(EntityTypeBuilder<PlatformBillingDunningNotificationEvent> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("platform_dunning_notification_events");
|
||||
builder.ConfigureTenantEntity("platform_billing_dunning_notification_events");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.Provider).HasSnakeCaseEnum();
|
||||
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
|
||||
@@ -284,15 +141,20 @@ internal sealed class PlatformDunningNotificationEventConfiguration : IEntityTyp
|
||||
builder.HasIndex(entity => new { entity.ReminderId, entity.Status, entity.CreatedAt });
|
||||
builder.HasIndex(entity => new { entity.InvoiceId, entity.Status, entity.CreatedAt });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.CreatedAt });
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_platform_dunning_events_attempts", "attempts >= 0"));
|
||||
builder.HasOne<PlatformDunningNotificationChannel>().WithMany().HasForeignKey(entity => entity.ChannelId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<TenantInvoiceReminder>().WithMany()
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_dunning_events_attempts", "attempts >= 0"));
|
||||
builder.HasOne<PlatformBillingDunningNotificationChannel>().WithMany()
|
||||
.HasForeignKey(entity => entity.ChannelId)
|
||||
.HasConstraintName("fk_platform_billing_dunning_events_channel")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<PlatformBillingInvoiceReminder>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.ReminderId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.HasConstraintName("fk_platform_billing_dunning_events_reminder")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<TenantInvoice>().WithMany()
|
||||
builder.HasOne<PlatformBillingInvoice>().WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.InvoiceId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.HasConstraintName("fk_platform_billing_dunning_events_invoice")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Configurations;
|
||||
|
||||
internal sealed class SaasFeatureConfiguration : IEntityTypeConfiguration<SaasFeature>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SaasFeature> builder)
|
||||
{
|
||||
builder.ConfigureEntity("saas_features");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.Code).HasMaxLength(120);
|
||||
builder.Property(value => value.Name).HasMaxLength(200);
|
||||
builder.Property(value => value.Category).HasMaxLength(100);
|
||||
builder.Property(value => value.Description).HasMaxLength(1000);
|
||||
builder.Property(value => value.Currency).HasMaxLength(10);
|
||||
builder.Property(value => value.Status).HasSnakeCaseEnum();
|
||||
builder.HasIndex(value => value.Code).IsUnique();
|
||||
builder.HasIndex(value => new { value.Status, value.Category, value.SortOrder });
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_saas_features_reference_price", "reference_price_cents >= 0"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PermissionModuleConfiguration : IEntityTypeConfiguration<PermissionModule>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PermissionModule> builder)
|
||||
{
|
||||
builder.ConfigureEntity("permission_modules");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.Code).HasMaxLength(120);
|
||||
builder.Property(value => value.Name).HasMaxLength(200);
|
||||
builder.Property(value => value.Area).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.RequiredFeatureCode).HasMaxLength(120);
|
||||
builder.Property(value => value.Description).HasMaxLength(1000);
|
||||
builder.HasIndex(value => value.Code).IsUnique();
|
||||
builder.HasIndex(value => new { value.Area, value.SortOrder });
|
||||
builder.HasOne<SaasFeature>().WithMany()
|
||||
.HasPrincipalKey(value => value.Code)
|
||||
.HasForeignKey(value => value.RequiredFeatureCode)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SaasOfferingConfiguration : IEntityTypeConfiguration<SaasOffering>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SaasOffering> builder)
|
||||
{
|
||||
builder.ConfigureEntity("saas_offerings");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.Code).HasMaxLength(120);
|
||||
builder.Property(value => value.Name).HasMaxLength(200);
|
||||
builder.Property(value => value.Type).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Status).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Description).HasMaxLength(1000);
|
||||
builder.HasIndex(value => value.Code).IsUnique();
|
||||
builder.HasIndex(value => new { value.Type, value.Status, value.SortOrder });
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SaasOfferingVersionConfiguration : IEntityTypeConfiguration<SaasOfferingVersion>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SaasOfferingVersion> builder)
|
||||
{
|
||||
builder.ConfigureEntity("saas_offering_versions");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.Status).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.BillingCycle).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Currency).HasMaxLength(10);
|
||||
builder.Property(value => value.Metadata).IsJson("{}");
|
||||
builder.HasIndex(value => new { value.OfferingId, value.Version }).IsUnique();
|
||||
builder.HasIndex(value => new { value.OfferingId, value.Status, value.EffectiveAt });
|
||||
builder.HasOne<SaasOffering>().WithMany().HasForeignKey(value => value.OfferingId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.ToTable(table =>
|
||||
{
|
||||
table.HasCheckConstraint("ck_saas_offering_versions_version", "version > 0");
|
||||
table.HasCheckConstraint("ck_saas_offering_versions_amount", "original_amount_cents >= 0 and amount_cents >= 0 and amount_cents <= original_amount_cents");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SaasOfferingVersionFeatureConfiguration : IEntityTypeConfiguration<SaasOfferingVersionFeature>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SaasOfferingVersionFeature> builder)
|
||||
{
|
||||
builder.ConfigureEntity("saas_offering_version_features");
|
||||
builder.Property(value => value.FeatureCode).HasMaxLength(120);
|
||||
builder.HasIndex(value => new { value.OfferingVersionId, value.FeatureCode }).IsUnique();
|
||||
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<SaasFeature>().WithMany().HasPrincipalKey(value => value.Code).HasForeignKey(value => value.FeatureCode).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SaasFeatureLimitDefinitionConfiguration : IEntityTypeConfiguration<SaasFeatureLimitDefinition>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SaasFeatureLimitDefinition> builder)
|
||||
{
|
||||
builder.ConfigureEntity("saas_feature_limit_definitions");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.MetricCode).HasMaxLength(120);
|
||||
builder.Property(value => value.FeatureCode).HasMaxLength(120);
|
||||
builder.Property(value => value.Name).HasMaxLength(200);
|
||||
builder.Property(value => value.Unit).HasMaxLength(50);
|
||||
builder.Property(value => value.Kind).HasSnakeCaseEnum();
|
||||
builder.HasIndex(value => value.MetricCode).IsUnique();
|
||||
builder.HasIndex(value => new { value.FeatureCode, value.Kind });
|
||||
builder.HasOne<SaasFeature>().WithMany().HasPrincipalKey(value => value.Code).HasForeignKey(value => value.FeatureCode).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_saas_feature_limit_warning", "warning_percent between 1 and 100"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SaasOfferingVersionLimitConfiguration : IEntityTypeConfiguration<SaasOfferingVersionLimit>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SaasOfferingVersionLimit> builder)
|
||||
{
|
||||
builder.ConfigureEntity("saas_offering_version_limits");
|
||||
builder.Property(value => value.MetricCode).HasMaxLength(120);
|
||||
builder.HasIndex(value => new { value.OfferingVersionId, value.MetricCode }).IsUnique();
|
||||
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<SaasFeatureLimitDefinition>().WithMany().HasPrincipalKey(value => value.MetricCode).HasForeignKey(value => value.MetricCode).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_saas_offering_version_limits_value", "limit_value >= 0"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantSaasSubscriptionConfiguration : IEntityTypeConfiguration<TenantSaasSubscription>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantSaasSubscription> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_saas_subscriptions");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.Status).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.LifecycleVersion).IsConcurrencyToken();
|
||||
builder.Property(value => value.Metadata).IsJson("{}");
|
||||
builder.HasIndex(value => new { value.TenantId, value.Status, value.CurrentPeriodEnd });
|
||||
builder.HasIndex(value => value.TenantId).IsUnique().HasFilter("status in ('trial', 'active', 'past_due', 'suspended')");
|
||||
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.BaseOfferingVersionId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.ScheduledBaseOfferingVersionId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_tenant_saas_subscriptions_period", "current_period_end > current_period_start and current_period_start >= starts_at"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantSaasSubscriptionItemConfiguration : IEntityTypeConfiguration<TenantSaasSubscriptionItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantSaasSubscriptionItem> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_saas_subscription_items");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.ItemType).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Status).HasSnakeCaseEnum();
|
||||
builder.HasIndex(value => new { value.TenantId, value.SubscriptionId, value.OfferingVersionId, value.Status });
|
||||
builder.HasIndex(value => new { value.TenantId, value.SubscriptionId, value.ItemType })
|
||||
.HasDatabaseName("ux_tenant_saas_subscription_items_current_base")
|
||||
.IsUnique()
|
||||
.HasFilter("item_type = 'base_plan' and status = 'active'");
|
||||
builder.HasIndex(value => new { value.TenantId, value.SubscriptionId, value.Status })
|
||||
.HasDatabaseName("ux_tenant_saas_subscription_items_scheduled_base")
|
||||
.IsUnique()
|
||||
.HasFilter("item_type = 'base_plan' and status = 'scheduled'");
|
||||
builder.HasOne<TenantSaasSubscription>().WithMany()
|
||||
.HasForeignKey(value => new { value.TenantId, value.SubscriptionId })
|
||||
.HasPrincipalKey(value => new { value.TenantId, value.Id })
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<PlatformBillingOrderItem>().WithMany()
|
||||
.HasForeignKey(value => new { value.TenantId, value.SourceOrderItemId })
|
||||
.HasPrincipalKey(value => new { value.TenantId, value.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_tenant_saas_subscription_items_period", "ends_at > starts_at"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantFeatureOverrideConfiguration : IEntityTypeConfiguration<TenantFeatureOverride>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantFeatureOverride> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_feature_overrides");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.FeatureCode).HasMaxLength(120);
|
||||
builder.Property(value => value.Mode).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Reason).HasMaxLength(1000);
|
||||
builder.HasIndex(value => new { value.TenantId, value.FeatureCode }).IsUnique();
|
||||
builder.HasOne<SaasFeature>().WithMany().HasPrincipalKey(value => value.Code).HasForeignKey(value => value.FeatureCode).OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TenantFeatureUsageConfiguration : IEntityTypeConfiguration<TenantFeatureUsage>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TenantFeatureUsage> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("tenant_feature_usage");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.MetricCode).HasMaxLength(120);
|
||||
builder.Property(value => value.Version).IsConcurrencyToken();
|
||||
builder.HasIndex(value => new { value.TenantId, value.MetricCode, value.PeriodStart, value.PeriodEnd }).IsUnique();
|
||||
builder.HasOne<SaasFeatureLimitDefinition>().WithMany().HasPrincipalKey(value => value.MetricCode).HasForeignKey(value => value.MetricCode).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.ToTable(table =>
|
||||
{
|
||||
table.HasCheckConstraint("ck_tenant_feature_usage_values", "used_value >= 0 and limit_value_snapshot >= 0");
|
||||
table.HasCheckConstraint("ck_tenant_feature_usage_period", "period_end > period_start");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlatformBillingQuoteConfiguration : IEntityTypeConfiguration<PlatformBillingQuote>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformBillingQuote> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("platform_billing_quotes");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.QuoteNo).HasMaxLength(100);
|
||||
builder.Property(value => value.IdempotencyKey).HasMaxLength(200);
|
||||
builder.Property(value => value.Status).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Purpose).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Currency).HasMaxLength(10);
|
||||
builder.Property(value => value.FeatureSnapshot).IsJson("[]");
|
||||
builder.Property(value => value.LimitSnapshot).IsJson("{}");
|
||||
builder.HasIndex(value => new { value.TenantId, value.QuoteNo }).IsUnique();
|
||||
builder.HasIndex(value => new { value.TenantId, value.IdempotencyKey }).IsUnique();
|
||||
builder.HasIndex(value => new { value.TenantId, value.Status, value.ExpiresAt });
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_quotes_amounts", "original_amount_cents >= 0 and discount_amount_cents >= 0 and total_amount_cents >= 0 and total_amount_cents = original_amount_cents - discount_amount_cents"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlatformBillingQuoteItemConfiguration : IEntityTypeConfiguration<PlatformBillingQuoteItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformBillingQuoteItem> builder)
|
||||
{
|
||||
builder.ConfigureEntity("platform_billing_quote_items");
|
||||
builder.HasAlternateKey(value => new { value.TenantId, value.Id });
|
||||
builder.Property(value => value.ItemType).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Snapshot).IsJson("{}");
|
||||
builder.HasIndex(value => new { value.TenantId, value.QuoteId, value.OfferingVersionId }).IsUnique();
|
||||
builder.HasOne<Tenant>().WithMany().HasForeignKey(value => value.TenantId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<PlatformBillingQuote>().WithMany().HasForeignKey(value => new { value.TenantId, value.QuoteId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_quote_items_amounts", "quantity > 0 and unit_amount_cents >= 0 and amount_cents >= 0"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlatformBillingOrderConfiguration : IEntityTypeConfiguration<PlatformBillingOrder>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformBillingOrder> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("platform_billing_orders");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.OrderNo).HasMaxLength(100);
|
||||
builder.Property(value => value.IdempotencyKey).HasMaxLength(200);
|
||||
builder.Property(value => value.Purpose).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Status).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Currency).HasMaxLength(10);
|
||||
builder.Property(value => value.Snapshot).IsJson("{}");
|
||||
builder.HasIndex(value => new { value.TenantId, value.OrderNo }).IsUnique();
|
||||
builder.HasIndex(value => new { value.TenantId, value.IdempotencyKey }).IsUnique();
|
||||
builder.HasIndex(value => new { value.TenantId, value.Status, value.CreatedAt });
|
||||
builder.HasOne<PlatformBillingQuote>().WithMany().HasForeignKey(value => new { value.TenantId, value.QuoteId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_orders_amounts", "original_amount_cents >= 0 and discount_amount_cents >= 0 and total_amount_cents >= 0 and total_amount_cents = original_amount_cents - discount_amount_cents"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlatformBillingOrderItemConfiguration : IEntityTypeConfiguration<PlatformBillingOrderItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformBillingOrderItem> builder)
|
||||
{
|
||||
builder.ConfigureEntity("platform_billing_order_items");
|
||||
builder.HasAlternateKey(value => new { value.TenantId, value.Id });
|
||||
builder.Property(value => value.ItemType).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Snapshot).IsJson("{}");
|
||||
builder.HasIndex(value => new { value.TenantId, value.OrderId, value.OfferingVersionId }).IsUnique();
|
||||
builder.HasOne<Tenant>().WithMany().HasForeignKey(value => value.TenantId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<PlatformBillingOrder>().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<SaasOfferingVersion>().WithMany().HasForeignKey(value => value.OfferingVersionId).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_order_items_amounts", "quantity > 0 and unit_amount_cents >= 0 and amount_cents >= 0"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlatformBillingPaymentConfiguration : IEntityTypeConfiguration<PlatformBillingPayment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformBillingPayment> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("platform_billing_payments");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.PaymentNo).HasMaxLength(100);
|
||||
builder.Property(value => value.IdempotencyKey).HasMaxLength(200);
|
||||
builder.Property(value => value.Provider).HasMaxLength(50);
|
||||
builder.Property(value => value.Method).HasMaxLength(50);
|
||||
builder.Property(value => value.Status).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.ProviderTradeNo).HasMaxLength(200);
|
||||
builder.Property(value => value.ClientPayload).IsJson("{}");
|
||||
builder.HasIndex(value => new { value.TenantId, value.PaymentNo }).IsUnique();
|
||||
builder.HasIndex(value => new { value.TenantId, value.IdempotencyKey }).IsUnique();
|
||||
builder.HasIndex(value => new { value.TenantId, value.OrderId, value.Status });
|
||||
builder.HasOne<PlatformBillingOrder>().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_payments_amount", "amount_cents >= 0"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlatformBillingPaymentEventConfiguration : IEntityTypeConfiguration<PlatformBillingPaymentEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformBillingPaymentEvent> builder)
|
||||
{
|
||||
builder.ConfigureEntity("platform_billing_payment_events");
|
||||
builder.HasAlternateKey(value => new { value.TenantId, value.Id });
|
||||
builder.Property(value => value.Provider).HasMaxLength(50);
|
||||
builder.Property(value => value.ProviderEventId).HasMaxLength(200);
|
||||
builder.Property(value => value.EventType).HasMaxLength(100);
|
||||
builder.Property(value => value.Payload).IsJson("{}");
|
||||
builder.Property(value => value.CreatedAt).HasDefaultValueSql("now()");
|
||||
builder.HasIndex(value => new { value.TenantId, value.Provider, value.ProviderEventId }).IsUnique();
|
||||
builder.HasIndex(value => new { value.TenantId, value.PaymentId, value.CreatedAt });
|
||||
builder.HasOne<Tenant>().WithMany().HasForeignKey(value => value.TenantId).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.HasOne<PlatformBillingPayment>().WithMany().HasForeignKey(value => new { value.TenantId, value.PaymentId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlatformBillingRefundConfiguration : IEntityTypeConfiguration<PlatformBillingRefund>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformBillingRefund> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("platform_billing_refunds");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.RefundNo).HasMaxLength(100);
|
||||
builder.Property(value => value.Status).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Reason).HasMaxLength(1000);
|
||||
builder.Property(value => value.ProviderRefundNo).HasMaxLength(200);
|
||||
builder.HasIndex(value => new { value.TenantId, value.RefundNo }).IsUnique();
|
||||
builder.HasIndex(value => new { value.TenantId, value.OrderId, value.Status });
|
||||
builder.HasOne<PlatformBillingOrder>().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.HasOne<PlatformBillingPayment>().WithMany().HasForeignKey(value => new { value.TenantId, value.PaymentId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_refunds_amount", "amount_cents > 0"));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlatformBillingInvoiceConfiguration : IEntityTypeConfiguration<PlatformBillingInvoice>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlatformBillingInvoice> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("platform_billing_invoices");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(value => value.InvoiceNo).HasMaxLength(100);
|
||||
builder.Property(value => value.Status).HasSnakeCaseEnum();
|
||||
builder.Property(value => value.Currency).HasMaxLength(10);
|
||||
builder.Property(value => value.BillingProfileSnapshot).IsJson("{}");
|
||||
builder.HasIndex(value => new { value.TenantId, value.InvoiceNo }).IsUnique();
|
||||
builder.HasIndex(value => new { value.TenantId, value.Status, value.DueDate });
|
||||
builder.HasOne<PlatformBillingOrder>().WithMany().HasForeignKey(value => new { value.TenantId, value.OrderId }).HasPrincipalKey(value => new { value.TenantId, value.Id }).OnDelete(DeleteBehavior.Restrict);
|
||||
builder.ToTable(table => table.HasCheckConstraint("ck_platform_billing_invoices_amount", "total_amount_cents >= 0"));
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,9 @@ internal sealed class TenantAuthPolicyConfiguration : IEntityTypeConfiguration<T
|
||||
builder.HasKey(entity => entity.TenantId);
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.AllowExternalStudentSelfRegistration).HasDefaultValue(false);
|
||||
builder.Property(entity => entity.AllowedStudentLoginMethods)
|
||||
.HasColumnType("text[]")
|
||||
.HasDefaultValueSql("ARRAY['password']::text[]");
|
||||
builder.HasOne<Tenant>().WithOne()
|
||||
.HasForeignKey<TenantAuthPolicy>(entity => entity.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,213 +0,0 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCommerceAdjustmentVouchers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "commerce_adjustment_vouchers",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
issue_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
batch_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
item_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
order_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
payment_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
refund_request_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
reviewed_by = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
voucher_no = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
direction = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
amount_cents = table.Column<int>(type: "integer", nullable: false),
|
||||
currency = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||
reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
|
||||
proof_asset_key = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
reviewed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
closed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_commerce_adjustment_vouchers", x => x.id);
|
||||
table.UniqueConstraint("ak_commerce_adjustment_vouchers_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.CheckConstraint("ck_commerce_adjustment_vouchers_amount", "amount_cents > 0");
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_batche~",
|
||||
columns: x => new { x.tenant_id, x.batch_id },
|
||||
principalTable: "commerce_reconciliation_batches",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_issues~",
|
||||
columns: x => new { x.tenant_id, x.issue_id },
|
||||
principalTable: "commerce_reconciliation_issues",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_commerce_reconciliation_items_~",
|
||||
columns: x => new { x.tenant_id, x.item_id },
|
||||
principalTable: "commerce_reconciliation_items",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_commerce_refund_requests_tenan~",
|
||||
columns: x => new { x.tenant_id, x.refund_request_id },
|
||||
principalTable: "commerce_refund_requests",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_orders_tenant_id_order_id",
|
||||
columns: x => new { x.tenant_id, x.order_id },
|
||||
principalTable: "orders",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_payments_tenant_id_payment_id",
|
||||
columns: x => new { x.tenant_id, x.payment_id },
|
||||
principalTable: "payments",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_users_created_by",
|
||||
column: x => x.created_by,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_vouchers_users_reviewed_by",
|
||||
column: x => x.reviewed_by,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "commerce_adjustment_voucher_events",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
voucher_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
from_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||
to_status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
actor_user_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
note = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
details = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_commerce_adjustment_voucher_events", x => x.id);
|
||||
table.UniqueConstraint("ak_commerce_adjustment_voucher_events_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_voucher_events_commerce_adjustment_vouc~",
|
||||
columns: x => new { x.tenant_id, x.voucher_id },
|
||||
principalTable: "commerce_adjustment_vouchers",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_voucher_events_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_commerce_adjustment_voucher_events_users_actor_user_id",
|
||||
column: x => x.actor_user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_voucher_events_actor_user_id",
|
||||
table: "commerce_adjustment_voucher_events",
|
||||
column: "actor_user_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_voucher_events_tenant_id_voucher_id_cre~",
|
||||
table: "commerce_adjustment_voucher_events",
|
||||
columns: new[] { "tenant_id", "voucher_id", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_created_by",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
column: "created_by");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_reviewed_by",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
column: "reviewed_by");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_batch_id",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "batch_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_issue_id_created_at",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "issue_id", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_item_id",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "item_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_order_id",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "order_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_payment_id",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "payment_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_refund_request_id",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "refund_request_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_status_created_at",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "status", "created_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_commerce_adjustment_vouchers_tenant_id_voucher_no",
|
||||
table: "commerce_adjustment_vouchers",
|
||||
columns: new[] { "tenant_id", "voucher_no" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "commerce_adjustment_voucher_events");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "commerce_adjustment_vouchers");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVideoPlaybackProgress : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "video_playback_progress",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
video_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
question_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
position_seconds = table.Column<int>(type: "integer", nullable: false),
|
||||
duration_seconds = table.Column<int>(type: "integer", nullable: true),
|
||||
watched_seconds = table.Column<int>(type: "integer", nullable: false),
|
||||
is_completed = table.Column<bool>(type: "boolean", nullable: false),
|
||||
completed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
last_played_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
play_count = table.Column<int>(type: "integer", nullable: false),
|
||||
metadata = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_video_playback_progress", x => x.id);
|
||||
table.UniqueConstraint("ak_video_playback_progress_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.CheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0");
|
||||
table.CheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0");
|
||||
table.CheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0");
|
||||
table.CheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0");
|
||||
table.ForeignKey(
|
||||
name: "fk_video_playback_progress_questions_tenant_id_question_id",
|
||||
columns: x => new { x.tenant_id, x.question_id },
|
||||
principalTable: "questions",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_video_playback_progress_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_video_playback_progress_users_user_id",
|
||||
column: x => x.user_id,
|
||||
principalTable: "users",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_video_playback_progress_video_explanations_tenant_id_video_~",
|
||||
columns: x => new { x.tenant_id, x.video_id },
|
||||
principalTable: "video_explanations",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_video_playback_progress_tenant_id_question_id",
|
||||
table: "video_playback_progress",
|
||||
columns: new[] { "tenant_id", "question_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_video_playback_progress_tenant_id_user_id_last_played_at",
|
||||
table: "video_playback_progress",
|
||||
columns: new[] { "tenant_id", "user_id", "last_played_at" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_video_playback_progress_tenant_id_user_id_video_id",
|
||||
table: "video_playback_progress",
|
||||
columns: new[] { "tenant_id", "user_id", "video_id" },
|
||||
unique: true,
|
||||
filter: "question_id is null");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_video_playback_progress_tenant_id_user_id_video_id_question~",
|
||||
table: "video_playback_progress",
|
||||
columns: new[] { "tenant_id", "user_id", "video_id", "question_id" },
|
||||
unique: true,
|
||||
filter: "question_id is not null");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_video_playback_progress_tenant_id_video_id",
|
||||
table: "video_playback_progress",
|
||||
columns: new[] { "tenant_id", "video_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_video_playback_progress_user_id",
|
||||
table: "video_playback_progress",
|
||||
column: "user_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "video_playback_progress");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveMfaAuthentication : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "mfa_satisfied",
|
||||
table: "auth_sessions");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "mfa_satisfied",
|
||||
table: "auth_sessions",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,297 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDistributedSecurityFoundation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddUniqueConstraint(
|
||||
name: "ak_platform_saas_plans_code",
|
||||
table: "platform_saas_plans",
|
||||
column: "code");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "inbox_state",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
message_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
consumer_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
lock_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
row_version = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true),
|
||||
received = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
receive_count = table.Column<int>(type: "integer", nullable: false),
|
||||
expiration_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
consumed = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
last_sequence_number = table.Column<long>(type: "bigint", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_inbox_state", x => x.id);
|
||||
table.UniqueConstraint("ak_inbox_state_message_id_consumer_id", x => new { x.message_id, x.consumer_id });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "outbox_state",
|
||||
columns: table => new
|
||||
{
|
||||
outbox_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
lock_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
row_version = table.Column<byte[]>(type: "bytea", rowVersion: true, nullable: true),
|
||||
created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
delivered = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
last_sequence_number = table.Column<long>(type: "bigint", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_outbox_state", x => x.outbox_id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "product_modules",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
sort_order = table.Column<int>(type: "integer", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_product_modules", x => x.id);
|
||||
table.UniqueConstraint("ak_product_modules_code", x => x.code);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_auth_policies",
|
||||
columns: table => new
|
||||
{
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
allow_external_student_self_registration = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_tenant_auth_policies", x => x.tenant_id);
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_auth_policies_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
// Existing tenants retain the historical external-login behavior. New tenants
|
||||
// receive an explicit fail-closed policy when created by PlatformAdminService.
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO tenant_auth_policies
|
||||
(tenant_id, allow_external_student_self_registration, created_at, updated_at)
|
||||
SELECT id, TRUE, now(), now()
|
||||
FROM tenants
|
||||
ON CONFLICT (tenant_id) DO NOTHING;
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "outbox_message",
|
||||
columns: table => new
|
||||
{
|
||||
sequence_number = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
enqueue_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
sent_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
headers = table.Column<string>(type: "text", nullable: true),
|
||||
properties = table.Column<string>(type: "text", nullable: true),
|
||||
inbox_message_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
inbox_consumer_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
outbox_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
message_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
content_type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||
message_type = table.Column<string>(type: "text", nullable: false),
|
||||
body = table.Column<string>(type: "text", nullable: false),
|
||||
conversation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
correlation_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
initiator_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
request_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
source_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
destination_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
response_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
fault_address = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
expiration_time = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_outbox_message", x => x.sequence_number);
|
||||
table.ForeignKey(
|
||||
name: "fk_outbox_message_inbox_state_inbox_message_id_inbox_consumer_~",
|
||||
columns: x => new { x.inbox_message_id, x.inbox_consumer_id },
|
||||
principalTable: "inbox_state",
|
||||
principalColumns: new[] { "message_id", "consumer_id" });
|
||||
table.ForeignKey(
|
||||
name: "fk_outbox_message_outbox_state_outbox_id",
|
||||
column: x => x.outbox_id,
|
||||
principalTable: "outbox_state",
|
||||
principalColumn: "outbox_id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "plan_module_entitlements",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
plan_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
module_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
enabled = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_plan_module_entitlements", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_plan_module_entitlements_platform_saas_plans_plan_code",
|
||||
column: x => x.plan_code,
|
||||
principalTable: "platform_saas_plans",
|
||||
principalColumn: "code",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_plan_module_entitlements_product_modules_module_code",
|
||||
column: x => x.module_code,
|
||||
principalTable: "product_modules",
|
||||
principalColumn: "code",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "tenant_module_overrides",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
module_code = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
expires_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
reason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_tenant_module_overrides", x => x.id);
|
||||
table.UniqueConstraint("ak_tenant_module_overrides_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_module_overrides_product_modules_module_code",
|
||||
column: x => x.module_code,
|
||||
principalTable: "product_modules",
|
||||
principalColumn: "code",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_tenant_module_overrides_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_inbox_state_delivered",
|
||||
table: "inbox_state",
|
||||
column: "delivered");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_message_enqueue_time",
|
||||
table: "outbox_message",
|
||||
column: "enqueue_time");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_message_expiration_time",
|
||||
table: "outbox_message",
|
||||
column: "expiration_time");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_message_inbox_message_id_inbox_consumer_id_sequence_~",
|
||||
table: "outbox_message",
|
||||
columns: new[] { "inbox_message_id", "inbox_consumer_id", "sequence_number" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_message_outbox_id_sequence_number",
|
||||
table: "outbox_message",
|
||||
columns: new[] { "outbox_id", "sequence_number" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_outbox_state_created",
|
||||
table: "outbox_state",
|
||||
column: "created");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_plan_module_entitlements_module_code",
|
||||
table: "plan_module_entitlements",
|
||||
column: "module_code");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_plan_module_entitlements_plan_code_module_code",
|
||||
table: "plan_module_entitlements",
|
||||
columns: new[] { "plan_code", "module_code" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_product_modules_code",
|
||||
table: "product_modules",
|
||||
column: "code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_module_overrides_module_code",
|
||||
table: "tenant_module_overrides",
|
||||
column: "module_code");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_tenant_module_overrides_tenant_id_module_code",
|
||||
table: "tenant_module_overrides",
|
||||
columns: new[] { "tenant_id", "module_code" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "outbox_message");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "plan_module_entitlements");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_auth_policies");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "tenant_module_overrides");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "inbox_state");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "outbox_state");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "product_modules");
|
||||
|
||||
migrationBuilder.DropUniqueConstraint(
|
||||
name: "ak_platform_saas_plans_code",
|
||||
table: "platform_saas_plans");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class EnforceProductModuleCatalog : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
INSERT INTO product_modules
|
||||
(id, code, name, status, sort_order, created_at, updated_at)
|
||||
VALUES
|
||||
('10000000-0000-0000-0000-000000000001', 'dashboard', 'Dashboard', 'active', 10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000002', 'staff', 'Staff', 'active', 20, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000003', 'role', 'Roles', 'active', 30, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000004', 'student', 'Students', 'active', 40, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000005', 'content', 'Content', 'active', 50, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000006', 'settings', 'Settings', 'active', 60, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000007', 'provider', 'Providers', 'active', 70, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000008', 'commerce', 'Commerce', 'active', 80, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-000000000009', 'crm', 'CRM', 'active', 90, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-00000000000a', 'commission', 'Commission', 'active', 100, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
|
||||
('10000000-0000-0000-0000-00000000000b', 'job', 'Background Jobs', 'active', 110, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
status = 'active',
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
INSERT INTO plan_module_entitlements (id, plan_code, module_code, enabled)
|
||||
SELECT
|
||||
md5('plan-module:' || plan.code || ':' || module.code)::uuid,
|
||||
plan.code,
|
||||
module.code,
|
||||
TRUE
|
||||
FROM platform_saas_plans AS plan
|
||||
CROSS JOIN product_modules AS module
|
||||
WHERE module.code IN
|
||||
('dashboard', 'staff', 'role', 'student', 'content', 'settings',
|
||||
'provider', 'commerce', 'crm', 'commission', 'job')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM plan_module_entitlements AS existing
|
||||
WHERE existing.plan_code = plan.code)
|
||||
ON CONFLICT (plan_code, module_code) DO NOTHING;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("""
|
||||
DELETE FROM product_modules
|
||||
WHERE code IN
|
||||
('dashboard', 'staff', 'role', 'student', 'content', 'settings',
|
||||
'provider', 'commerce', 'crm', 'commission', 'job');
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user