feat(saas): implement marketplace and tenant onboarding

This commit is contained in:
2026-07-29 13:58:59 +08:00
parent 76606029e2
commit 6db200a2fc
145 changed files with 16862 additions and 94529 deletions

View File

@@ -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)
## 常用命令

View File

@@ -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;

View File

@@ -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,

View File

@@ -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; }

View File

@@ -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; }

View File

@@ -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;

View File

@@ -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)

View File

@@ -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)

View File

@@ -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; }

View File

@@ -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()

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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; }

View File

@@ -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()

View File

@@ -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; }
}

View File

@@ -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,

View File

@@ -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()

View File

@@ -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);
}

View File

@@ -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; }

View File

@@ -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()

View File

@@ -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; }

View File

@@ -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()

View 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);
}

View File

@@ -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; }

View File

@@ -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; }

View File

@@ -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

View File

@@ -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; }

View File

@@ -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,

View File

@@ -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; }
}

View File

@@ -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; }

View File

@@ -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)]

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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()

View 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 = "成功" });
}
}

View 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");
}

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View File

@@ -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(

View 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");
}
}

View File

@@ -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(

View File

@@ -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)]

View File

@@ -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(

View 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);
}
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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);
}

View 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);
}
}

View File

@@ -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'],

View File

@@ -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,

View File

@@ -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);

View File

@@ -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)

View 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;
}

View File

@@ -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)

View File

@@ -0,0 +1,7 @@
namespace Tiku.Application.Security;
public enum CapabilityOperation
{
Read,
Write
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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());
}

View 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
};
}

View File

@@ -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";

View File

@@ -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
{

View 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);
}

View File

@@ -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 }

View File

@@ -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;

View File

@@ -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 }

View 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 }

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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
}));

View File

@@ -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
});

View File

@@ -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");
}

View File

@@ -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>();

View File

@@ -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)

View File

@@ -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)

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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"));
}
}

View File

@@ -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);

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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