feat: add student video and profile experience

This commit is contained in:
2026-07-28 15:23:42 +08:00
parent 5e993298e7
commit 7a3cf09a99
31 changed files with 19821 additions and 170 deletions

View File

@@ -25,6 +25,7 @@
- [`docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md`](docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md)
- [`docs/migration/phase-4-external-provider-decoupling.md`](docs/migration/phase-4-external-provider-decoupling.md)
- [`docs/migration/phase-5-backoffice-worker-operations.md`](docs/migration/phase-5-backoffice-worker-operations.md)
- [`docs/migration/phase-7-student-experience-and-content-consumption.md`](docs/migration/phase-7-student-experience-and-content-consumption.md)
## 技术栈与分层
@@ -132,6 +133,7 @@ Tiku.IntegrationTests # API / EF 模型集成测试
- Provider 配置统一落 `TenantExternalProvider` + `TenantSecret`
- `ConfigPublic` 只保存公开字段,例如 appId、merchantId、region、endpoint、bucketAlias、templateCode。
- 密钥只通过 `SecretRef` 关联 `TenantSecret`,禁止把 secret/token/key/privateKey 写入公开配置。
- AI 暂不实现;后续独立阶段默认使用 Microsoft Semantic Kernel租户模型 API Key 仍走 `TenantExternalProvider(capability=ai)` + `TenantSecret`,业务层不直接引用 SK namespace。
资源存储方向:
@@ -202,8 +204,12 @@ Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs
- question bank 只读
- vocabulary / handbook 只读
- asset / image / app asset / video catalog 只读
- student video search / play / progress
- question video list / batch query
- asset download / preview 授权签名
- profile check-in / score events
- tenant external providers / identity providers / payment providers 管理入口
- tenant-content generic import preview / sync import / async import job / import detail
- runtime bootstrap
## 新开发约束

View File

@@ -470,6 +470,7 @@ public sealed class DirectImportDto
public Guid? CategoryId { get; set; }
public Guid? QuestionBankId { get; set; }
public Guid? CollectionId { get; set; }
public bool? Async { get; set; }
public IReadOnlyCollection<JsonElement>? Items { get; set; }
public IReadOnlyCollection<JsonElement>? Units { get; set; }
public IReadOnlyCollection<JsonElement>? Words { get; set; }

View File

@@ -22,6 +22,13 @@ public sealed class ProfileQueryDto
public string? Category { get; set; }
public bool IncludeLocked { get; set; }
[StringLength(100)]
public string? SourceType { get; set; }
public DateTimeOffset? From { get; set; }
public DateTimeOffset? To { get; set; }
}
public sealed class UpdateProfileDto

View File

@@ -0,0 +1,74 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Tiku.Application.Assets;
namespace Tiku.Api.Contracts;
public sealed class VideoSearchQueryDto
{
[StringLength(100)]
public string? Keyword { get; set; }
public Guid? SubjectId { get; set; }
[Range(1, 200)]
public int? Limit { get; set; }
public VideoSearchQuery ToQuery() => new(Keyword, SubjectId, Limit);
}
public sealed class VideoPlayDto
{
[Required]
public Guid VideoId { get; set; }
public Guid? QuestionId { get; set; }
public VideoPlayCommand ToCommand() => new(VideoId, QuestionId);
}
public sealed class VideoProgressDto
{
[Required]
public Guid VideoId { get; set; }
public Guid? QuestionId { get; set; }
[Range(0, int.MaxValue)]
public int PositionSeconds { get; set; }
[Range(0, int.MaxValue)]
public int? DurationSeconds { get; set; }
[Range(0, int.MaxValue)]
public int? WatchedSeconds { get; set; }
public bool? IsCompleted { get; set; }
public JsonElement Metadata { get; set; } = JsonSerializer.SerializeToElement(new { });
public VideoProgressCommand ToCommand()
{
return new VideoProgressCommand(
VideoId,
QuestionId,
PositionSeconds,
DurationSeconds,
WatchedSeconds,
IsCompleted,
Metadata);
}
}
public sealed class QuestionVideoQueryDto
{
public Guid? QuestionId { get; set; }
[MaxLength(100)]
public IReadOnlyCollection<Guid>? QuestionIds { get; set; }
[Range(1, 500)]
public int? Limit { get; set; }
public QuestionVideoQuery ToQuery() => new(QuestionId, QuestionIds, Limit);
}

View File

@@ -107,6 +107,27 @@ public sealed class ProfileController(
cancellationToken));
}
[HttpPost("check-in")]
[EndpointSummary("每日签到")]
[ProducesResponseType<CheckInResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<CheckInResult>> CheckIn(CancellationToken cancellationToken)
{
return Ok(await profileService.CheckInAsync(ResolveActor(), cancellationToken));
}
[HttpGet("score-events")]
[EndpointSummary("查询积分流水")]
[ProducesResponseType<ProfileScoreEventList>(StatusCodes.Status200OK)]
public async Task<ActionResult<ProfileScoreEventList>> ScoreEvents(
[FromQuery] ProfileQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await profileService.GetScoreEventsAsync(
ResolveActor(),
new ProfileScoreEventQuery(query.Limit, query.SourceType, query.From, query.To),
cancellationToken));
}
[HttpPost("feedbacks")]
[EndpointSummary("提交意见反馈")]
[ProducesResponseType<FeedbackItem>(StatusCodes.Status200OK)]

View File

@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
[Produces("application/json")]
[Route("api/questions/videos")]
public sealed class QuestionVideosController(
IVideoPlaybackService videoPlaybackService,
ICurrentUser currentUser,
ITenantContext currentTenant) : ControllerBase
{
[HttpGet]
[EndpointSummary("查询单道题目的解析视频")]
[ProducesResponseType<CatalogList<QuestionVideoCatalogItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<QuestionVideoCatalogItem>>> List(
[FromQuery] QuestionVideoQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await videoPlaybackService.GetQuestionVideosAsync(ResolveActor(), query.ToQuery(), cancellationToken));
}
[HttpPost("batch")]
[EndpointSummary("批量查询题目解析视频")]
[ProducesResponseType<CatalogList<QuestionVideoCatalogItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<QuestionVideoCatalogItem>>> Batch(
QuestionVideoQueryDto request,
CancellationToken cancellationToken)
{
return Ok(await videoPlaybackService.GetQuestionVideosAsync(ResolveActor(), request.ToQuery(), cancellationToken));
}
private VideoPlaybackActor ResolveActor()
{
if (currentTenant.TenantId is null || currentUser.UserId is null)
{
throw new VideoPlaybackException("Current video actor was not resolved.", "video_access_denied");
}
return new VideoPlaybackActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
}
}

View File

@@ -4,6 +4,7 @@ using Tiku.Api.Contracts;
using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Application.Content;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
@@ -16,6 +17,7 @@ namespace Tiku.Api.Controllers;
[Route("api/tenant-content")]
public sealed class TenantContentDirectController(
IDirectContentService directContentService,
IBackgroundJobService backgroundJobService,
ICurrentUser currentUser,
ITenantContext currentTenant) : ControllerBase
{
@@ -318,14 +320,53 @@ public sealed class TenantContentDirectController(
[HttpPost("imports/{importType}")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("执行同步内容导入")]
[EndpointSummary("执行或排队内容导入")]
[ProducesResponseType<SimpleImportResult>(StatusCodes.Status200OK)]
public async Task<ActionResult<SimpleImportResult>> ExecuteImport(
[ProducesResponseType<BackgroundJobItem>(StatusCodes.Status202Accepted)]
public async Task<ActionResult<object>> ExecuteImport(
string importType,
DirectImportDto request,
CancellationToken cancellationToken)
{
return Ok(await directContentService.ExecuteImportAsync(ResolveActor(), request.ToCommand(importType, dryRun: false), cancellationToken));
var actor = ResolveActor();
var command = request.ToCommand(importType, dryRun: false);
if (request.Async == true || command.Items.Count > 100)
{
var job = await backgroundJobService.EnqueueAsync(
new CreateBackgroundJobCommand(
actor.TenantId,
"content_import",
System.Text.Json.JsonSerializer.SerializeToElement(new
{
createdBy = actor.UserId,
importType = command.ImportType,
sourceFormat = command.SourceFormat,
sourceName = command.SourceName,
regionId = command.RegionId,
entryId = command.EntryId,
contentNodeId = command.ContentNodeId,
subjectId = command.SubjectId,
categoryId = command.CategoryId,
questionBankId = command.QuestionBankId,
collectionId = command.CollectionId,
items = command.Items
})),
cancellationToken);
return Accepted(job);
}
return Ok(await directContentService.ExecuteImportAsync(actor, command, cancellationToken));
}
[HttpGet("imports/detail")]
[Authorize(Policy = TikuPolicies.TenantContentManageAllScope)]
[EndpointSummary("查询内容导入任务详情")]
[ProducesResponseType<ContentImportJobDetail>(StatusCodes.Status200OK)]
public async Task<ActionResult<ContentImportJobDetail>> GetImportDetail(
[FromQuery] DirectImportJobDto query,
CancellationToken cancellationToken)
{
return Ok(await directContentService.GetImportJobAsync(ResolveActor(), query.JobId, cancellationToken));
}
[HttpGet("imports/issues")]

View File

@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tiku.Api.Contracts;
using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Application.Security;
namespace Tiku.Api.Controllers;
[ApiController]
[Authorize(Policy = TikuPolicies.CurrentTenantMember)]
[Produces("application/json")]
[Route("api/videos")]
public sealed class VideosController(
IVideoPlaybackService videoPlaybackService,
ICurrentUser currentUser,
ITenantContext currentTenant) : ControllerBase
{
[HttpGet("search")]
[EndpointSummary("搜索通用解析视频")]
[ProducesResponseType<CatalogList<VideoExplanationCatalogItem>>(StatusCodes.Status200OK)]
public async Task<ActionResult<CatalogList<VideoExplanationCatalogItem>>> Search(
[FromQuery] VideoSearchQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await videoPlaybackService.SearchAsync(ResolveActor(), query.ToQuery(), cancellationToken));
}
[HttpPost("play")]
[EndpointSummary("申请视频播放信息")]
[ProducesResponseType<VideoPlaybackItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<VideoPlaybackItem>> Play(
VideoPlayDto request,
CancellationToken cancellationToken)
{
return Ok(await videoPlaybackService.PlayAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
[HttpPost("progress")]
[EndpointSummary("上报视频播放进度")]
[ProducesResponseType<VideoProgressItem>(StatusCodes.Status200OK)]
public async Task<ActionResult<VideoProgressItem>> Progress(
VideoProgressDto request,
CancellationToken cancellationToken)
{
return Ok(await videoPlaybackService.ReportProgressAsync(ResolveActor(), request.ToCommand(), cancellationToken));
}
private VideoPlaybackActor ResolveActor()
{
if (currentTenant.TenantId is null || currentUser.UserId is null)
{
throw new VideoPlaybackException("Current video actor was not resolved.", "video_access_denied");
}
return new VideoPlaybackActor(currentTenant.TenantId.Value, currentUser.UserId.Value);
}
}

View File

@@ -156,6 +156,16 @@ public sealed class ExceptionHandlingMiddleware(
return;
}
if (exception is VideoPlaybackException videoPlaybackException)
{
await WriteProblemAsync(
context,
videoPlaybackException.Message,
VideoPlaybackStatusCode(videoPlaybackException.Code),
videoPlaybackException.Code);
return;
}
if (exception is ContentManagementException contentManagementException)
{
await WriteProblemAsync(
@@ -436,12 +446,22 @@ public sealed class ExceptionHandlingMiddleware(
return code switch
{
"profile_access_denied" => StatusCodes.Status403Forbidden,
"profile_user_not_found" => StatusCodes.Status404NotFound,
"profile_user_not_found" or "check_in_task_not_found" => StatusCodes.Status404NotFound,
"region_not_found" or "school_not_found" or "major_not_found" => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};
}
private static int VideoPlaybackStatusCode(string code)
{
return code switch
{
"video_access_denied" => StatusCodes.Status403Forbidden,
"video_not_found" or "question_video_not_found" => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};
}
private static int TenantAdminDirectStatusCode(string code)
{
return code switch

View File

@@ -0,0 +1,80 @@
using System.Text.Json;
using Tiku.Application.Catalog;
using Tiku.Domain.Content;
namespace Tiku.Application.Assets;
public sealed record VideoPlaybackActor(Guid TenantId, Guid UserId);
public sealed record VideoSearchQuery(
string? Keyword = null,
Guid? SubjectId = null,
int? Limit = null);
public sealed record QuestionVideoQuery(
Guid? QuestionId = null,
IReadOnlyCollection<Guid>? QuestionIds = null,
int? Limit = null);
public sealed record VideoPlayCommand(Guid VideoId, Guid? QuestionId = null);
public sealed record VideoProgressCommand(
Guid VideoId,
Guid? QuestionId,
int PositionSeconds,
int? DurationSeconds,
int? WatchedSeconds,
bool? IsCompleted,
JsonElement Metadata);
public sealed record VideoPlaybackItem(
Guid VideoId,
Guid? QuestionId,
string Title,
string? Description,
string? PlayUrl,
string? ThumbnailUrl,
int? DurationSeconds,
VideoProgressItem? Progress,
JsonElement Metadata);
public sealed record VideoProgressItem(
Guid Id,
Guid VideoId,
Guid? QuestionId,
int PositionSeconds,
int? DurationSeconds,
int WatchedSeconds,
bool IsCompleted,
DateTimeOffset? CompletedAt,
DateTimeOffset LastPlayedAt,
int PlayCount,
JsonElement Metadata);
public interface IVideoPlaybackService
{
Task<CatalogList<VideoExplanationCatalogItem>> SearchAsync(
VideoPlaybackActor actor,
VideoSearchQuery query,
CancellationToken cancellationToken = default);
Task<VideoPlaybackItem> PlayAsync(
VideoPlaybackActor actor,
VideoPlayCommand command,
CancellationToken cancellationToken = default);
Task<VideoProgressItem> ReportProgressAsync(
VideoPlaybackActor actor,
VideoProgressCommand command,
CancellationToken cancellationToken = default);
Task<CatalogList<QuestionVideoCatalogItem>> GetQuestionVideosAsync(
VideoPlaybackActor actor,
QuestionVideoQuery query,
CancellationToken cancellationToken = default);
}
public sealed class VideoPlaybackException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}

View File

@@ -346,6 +346,7 @@ public interface IDirectContentService
Task<ContentManagementResult<OperationContentItem>> UpsertOperationContentAsync(DirectContentActor actor, string kind, OperationContentCommand command, CancellationToken cancellationToken = default);
Task<SimpleImportResult> PreviewImportAsync(DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default);
Task<SimpleImportResult> ExecuteImportAsync(DirectContentActor actor, SimpleImportCommand command, CancellationToken cancellationToken = default);
Task<ContentImportJobDetail> GetImportJobAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default);
Task<CatalogList<ContentImportIssueModel>> GetImportIssuesAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default);
Task<ImportPostCheckResult> RunImportPostCheckAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default);
Task<ImportPostCheckResult> GetImportPostCheckAsync(DirectContentActor actor, Guid jobId, CancellationToken cancellationToken = default);

View File

@@ -6,6 +6,12 @@ public sealed record ProfileActor(Guid TenantId, Guid UserId);
public sealed record ProfileQuery(int? RecentLimit = null, int? Limit = null);
public sealed record ProfileScoreEventQuery(
int? Limit = null,
string? SourceType = null,
DateTimeOffset? From = null,
DateTimeOffset? To = null);
public sealed record UpdateProfileCommand(
string? Name,
string? AvatarPreset,
@@ -144,6 +150,26 @@ public sealed record SubmitFeedbackCommand(
JsonElement Attachments,
JsonElement Metadata);
public sealed record CheckInResult(
DateOnly CheckInDate,
bool AlreadyCheckedIn,
int Points,
int BalanceAfter,
Guid? ClaimId,
Guid? ScoreEventId);
public sealed record ProfileScoreEventItem(
Guid Id,
string EventType,
int Points,
int BalanceAfter,
string? SourceType,
Guid? SourceId,
JsonElement Metadata,
DateTimeOffset CreatedAt);
public sealed record ProfileScoreEventList(IReadOnlyCollection<ProfileScoreEventItem> Items);
public interface IProfileService
{
Task<StudentProfileItem> GetMeAsync(ProfileActor actor, ProfileQuery query, CancellationToken cancellationToken = default);
@@ -154,4 +180,6 @@ public interface IProfileService
Task<BadgeList> GetBadgesAsync(ProfileActor actor, BadgeQuery query, CancellationToken cancellationToken = default);
Task<IReadOnlyCollection<FeedbackItem>> GetFeedbacksAsync(ProfileActor actor, FeedbackQuery query, CancellationToken cancellationToken = default);
Task<FeedbackItem> SubmitFeedbackAsync(ProfileActor actor, SubmitFeedbackCommand command, CancellationToken cancellationToken = default);
Task<CheckInResult> CheckInAsync(ProfileActor actor, CancellationToken cancellationToken = default);
Task<ProfileScoreEventList> GetScoreEventsAsync(ProfileActor actor, ProfileScoreEventQuery query, CancellationToken cancellationToken = default);
}

View File

@@ -155,6 +155,21 @@ public sealed class QuestionVideo : AuditableTenantEntity
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class VideoPlaybackProgress : AuditableTenantEntity
{
public Guid UserId { get; set; }
public Guid VideoId { get; set; }
public Guid? QuestionId { get; set; }
public int PositionSeconds { get; set; }
public int? DurationSeconds { get; set; }
public int WatchedSeconds { get; set; }
public bool IsCompleted { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public DateTimeOffset LastPlayedAt { get; set; } = DateTimeOffset.UtcNow;
public int PlayCount { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public enum ContentAssetType
{
Pdf,

View File

@@ -0,0 +1,274 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Assets;
public sealed class VideoPlaybackService(TikuDbContext dbContext) : IVideoPlaybackService
{
public async Task<CatalogList<VideoExplanationCatalogItem>> SearchAsync(
VideoPlaybackActor actor,
VideoSearchQuery query,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var videos = dbContext.VideoExplanations.AsNoTracking()
.Where(video => video.TenantId == actor.TenantId && video.IsActive);
if (query.SubjectId.HasValue)
{
videos = videos.Where(video => video.SubjectId == query.SubjectId.Value || video.SubjectId == null);
}
if (!string.IsNullOrWhiteSpace(query.Keyword))
{
var keyword = query.Keyword.Trim();
videos = videos.Where(video =>
video.Title.Contains(keyword) ||
(video.Description != null && video.Description.Contains(keyword)));
}
var items = await videos
.OrderBy(video => video.SortOrder)
.ThenBy(video => video.Title)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.Select(video => ToVideoItem(video))
.ToArrayAsync(cancellationToken);
return new CatalogList<VideoExplanationCatalogItem>(items);
}
public async Task<VideoPlaybackItem> PlayAsync(
VideoPlaybackActor actor,
VideoPlayCommand command,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken);
if (command.QuestionId.HasValue)
{
await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, cancellationToken);
}
var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken);
progress.PlayCount++;
progress.LastPlayedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
dbContext.ContentAssetAccessEvents.Add(new ContentAssetAccessEvent
{
TenantId = actor.TenantId,
UserId = actor.UserId,
ActorRole = AssetAccessActorRole.Student,
AccessType = AssetAccessType.Preview,
AssetType = ContentAssetType.Video.ToString(),
Result = AssetAccessResult.Granted,
Metadata = JsonSerializer.SerializeToElement(new
{
videoId = video.Id,
command.QuestionId,
source = "student_video_play"
})
});
await dbContext.SaveChangesAsync(cancellationToken);
return new VideoPlaybackItem(
video.Id,
command.QuestionId,
video.Title,
video.Description,
video.VideoUrl,
video.ThumbnailUrl,
video.DurationSeconds,
ToProgressItem(progress),
video.Metadata);
}
public async Task<VideoProgressItem> ReportProgressAsync(
VideoPlaybackActor actor,
VideoProgressCommand command,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var video = await ResolveVideoAsync(actor.TenantId, command.VideoId, cancellationToken);
if (command.QuestionId.HasValue)
{
await AssertQuestionVideoAsync(actor.TenantId, command.QuestionId.Value, command.VideoId, cancellationToken);
}
var progress = await ResolveProgressAsync(actor, command.VideoId, command.QuestionId, cancellationToken);
var positionSeconds = Math.Max(command.PositionSeconds, 0);
var durationSeconds = command.DurationSeconds ?? video.DurationSeconds;
var watchedSeconds = Math.Max(command.WatchedSeconds ?? positionSeconds, progress.WatchedSeconds);
var completed = command.IsCompleted == true ||
durationSeconds is > 0 && positionSeconds >= Math.Max(0, durationSeconds.Value - 3);
progress.PositionSeconds = positionSeconds;
progress.DurationSeconds = durationSeconds;
progress.WatchedSeconds = watchedSeconds;
progress.IsCompleted = completed;
progress.CompletedAt = completed ? progress.CompletedAt ?? DateTimeOffset.UtcNow : progress.CompletedAt;
progress.LastPlayedAt = DateTimeOffset.UtcNow;
progress.Metadata = command.Metadata.ValueKind == JsonValueKind.Object ? command.Metadata.Clone() : JsonDefaults.Object();
await dbContext.SaveChangesAsync(cancellationToken);
return ToProgressItem(progress);
}
public async Task<CatalogList<QuestionVideoCatalogItem>> GetQuestionVideosAsync(
VideoPlaybackActor actor,
QuestionVideoQuery query,
CancellationToken cancellationToken = default)
{
await AssertActiveMemberAsync(actor, cancellationToken);
var questionVideos = dbContext.QuestionVideos.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId);
if (query.QuestionId.HasValue)
{
questionVideos = questionVideos.Where(item => item.QuestionId == query.QuestionId.Value);
}
if (query.QuestionIds is { Count: > 0 })
{
questionVideos = questionVideos.Where(item => item.QuestionId.HasValue && query.QuestionIds.Contains(item.QuestionId.Value));
}
var videos = dbContext.VideoExplanations.AsNoTracking().Where(item => item.TenantId == actor.TenantId && item.IsActive);
var items = await questionVideos
.OrderBy(item => item.SortOrder)
.ThenBy(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 100, 1, 500))
.GroupJoin(
videos,
questionVideo => new { questionVideo.TenantId, Id = questionVideo.VideoId },
video => new { video.TenantId, Id = (Guid?)video.Id },
(questionVideo, matchedVideos) => new
{
QuestionVideo = questionVideo,
Video = matchedVideos.FirstOrDefault()
})
.Select(row => new QuestionVideoCatalogItem(
row.QuestionVideo.Id,
row.QuestionVideo.LegacyId,
row.QuestionVideo.QuestionId,
row.QuestionVideo.VideoId,
row.QuestionVideo.VideoType,
row.QuestionVideo.SortOrder,
row.QuestionVideo.Metadata,
row.Video == null ? null : ToVideoItem(row.Video)))
.ToArrayAsync(cancellationToken);
return new CatalogList<QuestionVideoCatalogItem>(items);
}
private async Task<VideoPlaybackProgress> ResolveProgressAsync(
VideoPlaybackActor actor,
Guid videoId,
Guid? questionId,
CancellationToken cancellationToken)
{
var progress = await dbContext.VideoPlaybackProgress.SingleOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.VideoId == videoId &&
item.QuestionId == questionId,
cancellationToken);
if (progress is not null)
{
return progress;
}
progress = new VideoPlaybackProgress
{
TenantId = actor.TenantId,
UserId = actor.UserId,
VideoId = videoId,
QuestionId = questionId,
LastPlayedAt = DateTimeOffset.UtcNow,
Metadata = JsonDefaults.Object()
};
dbContext.VideoPlaybackProgress.Add(progress);
return progress;
}
private async Task<VideoExplanation> ResolveVideoAsync(
Guid tenantId,
Guid videoId,
CancellationToken cancellationToken)
{
return await dbContext.VideoExplanations.SingleOrDefaultAsync(
video => video.TenantId == tenantId && video.Id == videoId && video.IsActive,
cancellationToken)
?? throw new VideoPlaybackException("Video was not found.", "video_not_found");
}
private async Task AssertQuestionVideoAsync(
Guid tenantId,
Guid questionId,
Guid videoId,
CancellationToken cancellationToken)
{
var exists = await dbContext.QuestionVideos.AnyAsync(
item =>
item.TenantId == tenantId &&
item.QuestionId == questionId &&
item.VideoId == videoId,
cancellationToken);
if (!exists)
{
throw new VideoPlaybackException("Question video was not found.", "question_video_not_found");
}
}
private async Task AssertActiveMemberAsync(VideoPlaybackActor actor, CancellationToken cancellationToken)
{
var exists = await dbContext.TenantMemberships.AnyAsync(
membership =>
membership.TenantId == actor.TenantId &&
membership.UserId == actor.UserId &&
membership.Status == MembershipStatus.Active,
cancellationToken);
if (!exists)
{
throw new VideoPlaybackException("Current user is not a member of the tenant.", "video_access_denied");
}
}
private static VideoExplanationCatalogItem ToVideoItem(VideoExplanation video)
{
return new VideoExplanationCatalogItem(
video.Id,
video.LegacyId,
video.SubjectId,
video.Title,
video.Description,
video.VideoUrl,
video.ThumbnailUrl,
video.DurationSeconds,
video.KnowledgeTags,
video.IsGeneral,
video.Difficulty,
video.SortOrder,
video.IsActive,
video.Metadata);
}
private static VideoProgressItem ToProgressItem(VideoPlaybackProgress progress)
{
return new VideoProgressItem(
progress.Id,
progress.VideoId,
progress.QuestionId,
progress.PositionSeconds,
progress.DurationSeconds,
progress.WatchedSeconds,
progress.IsCompleted,
progress.CompletedAt,
progress.LastPlayedAt,
progress.PlayCount,
progress.Metadata);
}
}

View File

@@ -1025,6 +1025,46 @@ public sealed class DirectContentService(
return CreateImportJobAsync(actor, command with { DryRun = false }, execute: true, cancellationToken);
}
public async Task<ContentImportJobDetail> GetImportJobAsync(
DirectContentActor actor,
Guid jobId,
CancellationToken cancellationToken = default)
{
var job = await dbContext.ContentImportJobs.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.Id == jobId)
.Select(item => ToJobItem(item))
.SingleOrDefaultAsync(cancellationToken);
if (job is null)
{
throw new ContentManagementException("Import job was not found.", "import_job_not_found");
}
var items = await dbContext.ContentImportItems.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.JobId == jobId)
.OrderBy(item => item.RowNo)
.Take(MaxLimit)
.Select(item => ToImportItem(item))
.ToArrayAsync(cancellationToken);
var issues = await dbContext.ContentImportIssues.AsNoTracking()
.Where(issue => issue.TenantId == actor.TenantId && issue.JobId == jobId)
.OrderBy(issue => issue.RowNo)
.ThenBy(issue => issue.CreatedAt)
.Take(MaxLimit)
.Select(issue => new ContentImportIssueModel(
issue.Id,
issue.JobId,
issue.ItemId,
issue.RowNo,
issue.Severity,
issue.Code,
issue.FieldPath,
issue.Message,
issue.Details))
.ToArrayAsync(cancellationToken);
return new ContentImportJobDetail(job, items, issues);
}
public async Task<CatalogList<ContentImportIssueModel>> GetImportIssuesAsync(
DirectContentActor actor,
Guid jobId,

View File

@@ -112,6 +112,7 @@ public static class DependencyInjection
services.AddScoped<IAssetQueryService, AssetQueryService>();
services.AddScoped<IAssetAccessService, AssetAccessService>();
services.AddScoped<IAssetManagementService, AssetManagementService>();
services.AddScoped<IVideoPlaybackService, VideoPlaybackService>();
services.AddScoped<ILearningActivityService, LearningActivityService>();
services.AddScoped<ITenantAdminDirectService, TenantAdminDirectService>();
services.AddScoped<IBackofficeService, BackofficeService>();

View File

@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json;
using Tiku.Application.Content;
using Tiku.Application.Jobs;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
@@ -65,7 +66,7 @@ internal sealed class BackgroundJobService(
var result = await tenantExecutionScope.ExecuteAsync(
job.TenantId,
$"Background job {job.JobType}",
(_, token) => ProcessCoreAsync(job, token),
(provider, token) => ProcessCoreAsync(provider, job, token),
cancellationToken);
job.Status = BackgroundJobStatus.Succeeded;
job.CompletedAt = DateTimeOffset.UtcNow;
@@ -114,13 +115,16 @@ internal sealed class BackgroundJobService(
return jobs.Select(ToItem).ToArray();
}
private async Task<JsonElement> ProcessCoreAsync(BackgroundJob job, CancellationToken cancellationToken)
private async Task<JsonElement> ProcessCoreAsync(
IServiceProvider scopedProvider,
BackgroundJob job,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return job.JobType switch
{
"content_export" => await ProcessContentExportAsync(job, cancellationToken),
"content_import" => throw new NotSupportedException("content_import requires the module-specific importer before it can mutate content."),
"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(job, cancellationToken),
"commerce_reconciliation" => await ProcessCommerceReconciliationAsync(job, cancellationToken),
@@ -129,6 +133,47 @@ internal sealed class BackgroundJobService(
};
}
private static async Task<JsonElement> ProcessContentImportAsync(
IServiceProvider scopedProvider,
BackgroundJob job,
CancellationToken cancellationToken)
{
var directContentService = scopedProvider.GetRequiredService<IDirectContentService>();
var createdBy = GetJsonGuid(job.Payload, "createdBy") ?? Guid.Empty;
if (createdBy == Guid.Empty)
{
throw new InvalidOperationException("content_import job requires createdBy.");
}
var command = new SimpleImportCommand(
GetJsonString(job.Payload, "importType") ?? throw new InvalidOperationException("content_import job requires importType."),
GetJsonString(job.Payload, "sourceFormat"),
GetJsonString(job.Payload, "sourceName"),
GetJsonGuid(job.Payload, "regionId"),
GetJsonGuid(job.Payload, "entryId"),
GetJsonGuid(job.Payload, "contentNodeId"),
GetJsonGuid(job.Payload, "subjectId"),
GetJsonGuid(job.Payload, "categoryId"),
GetJsonGuid(job.Payload, "questionBankId"),
GetJsonGuid(job.Payload, "collectionId"),
GetJsonArray(job.Payload, "items"),
false);
var result = await directContentService.ExecuteImportAsync(
new DirectContentActor(job.TenantId, createdBy),
command,
cancellationToken);
return JsonSerializer.SerializeToElement(new
{
importJobId = result.Job.Id,
result.Job.ImportType,
result.Job.Status,
result.Job.TotalCount,
result.Job.InsertedCount,
result.Job.UpdatedCount,
result.Job.ErrorCount
});
}
private async Task<JsonElement> ProcessContentExportAsync(
BackgroundJob job,
CancellationToken cancellationToken)
@@ -300,6 +345,31 @@ internal sealed class BackgroundJobService(
: null;
}
private static Guid? GetJsonGuid(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object ||
!element.TryGetProperty(propertyName, out var property))
{
return null;
}
return property.ValueKind == JsonValueKind.String && Guid.TryParse(property.GetString(), out var value)
? value
: null;
}
private static IReadOnlyCollection<JsonElement> GetJsonArray(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object ||
!element.TryGetProperty(propertyName, out var property) ||
property.ValueKind != JsonValueKind.Array)
{
return [];
}
return property.EnumerateArray().Select(item => item.Clone()).ToArray();
}
private static DateOnly? GetJsonDateOnly(JsonElement element, string propertyName)
{
var value = GetJsonString(element, propertyName);

View File

@@ -310,3 +310,41 @@ internal sealed class QuestionVideoConfiguration : IEntityTypeConfiguration<Ques
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class VideoPlaybackProgressConfiguration : IEntityTypeConfiguration<VideoPlaybackProgress>
{
public void Configure(EntityTypeBuilder<VideoPlaybackProgress> builder)
{
builder.ConfigureTenantEntity("video_playback_progress");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.Property(entity => entity.LastPlayedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.VideoId })
.IsUnique()
.HasFilter("question_id is null");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.VideoId, entity.QuestionId })
.IsUnique()
.HasFilter("question_id is not null");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.LastPlayedAt });
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<VideoExplanation>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.VideoId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.ToTable(table =>
{
table.HasCheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0");
table.HasCheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0");
table.HasCheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0");
table.HasCheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0");
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
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

@@ -6579,6 +6579,118 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("video_explanations", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Content.VideoPlaybackProgress", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("completed_at");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<int?>("DurationSeconds")
.HasColumnType("integer")
.HasColumnName("duration_seconds");
b.Property<bool>("IsCompleted")
.HasColumnType("boolean")
.HasColumnName("is_completed");
b.Property<DateTimeOffset>("LastPlayedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("last_played_at")
.HasDefaultValueSql("now()");
b.Property<JsonElement>("Metadata")
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<int>("PlayCount")
.HasColumnType("integer")
.HasColumnName("play_count");
b.Property<int>("PositionSeconds")
.HasColumnType("integer")
.HasColumnName("position_seconds");
b.Property<Guid?>("QuestionId")
.HasColumnType("uuid")
.HasColumnName("question_id");
b.Property<Guid>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<Guid>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<Guid>("VideoId")
.HasColumnType("uuid")
.HasColumnName("video_id");
b.Property<int>("WatchedSeconds")
.HasColumnType("integer")
.HasColumnName("watched_seconds");
b.HasKey("Id")
.HasName("pk_video_playback_progress");
b.HasAlternateKey("TenantId", "Id")
.HasName("ak_video_playback_progress_tenant_id_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_video_playback_progress_user_id");
b.HasIndex("TenantId", "QuestionId")
.HasDatabaseName("ix_video_playback_progress_tenant_id_question_id");
b.HasIndex("TenantId", "VideoId")
.HasDatabaseName("ix_video_playback_progress_tenant_id_video_id");
b.HasIndex("TenantId", "UserId", "LastPlayedAt")
.HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_last_played_at");
b.HasIndex("TenantId", "UserId", "VideoId")
.IsUnique()
.HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_video_id")
.HasFilter("question_id is null");
b.HasIndex("TenantId", "UserId", "VideoId", "QuestionId")
.IsUnique()
.HasDatabaseName("ix_video_playback_progress_tenant_id_user_id_video_id_question~")
.HasFilter("question_id is not null");
b.ToTable("video_playback_progress", null, t =>
{
t.HasCheckConstraint("ck_video_playback_progress_duration", "duration_seconds is null or duration_seconds >= 0");
t.HasCheckConstraint("ck_video_playback_progress_play_count", "play_count >= 0");
t.HasCheckConstraint("ck_video_playback_progress_position", "position_seconds >= 0");
t.HasCheckConstraint("ck_video_playback_progress_watched", "watched_seconds >= 0");
});
});
modelBuilder.Entity("Tiku.Domain.Content.VocabularyUnit", b =>
{
b.Property<Guid>("Id")
@@ -16197,6 +16309,38 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_video_explanations_subjects_tenant_id_subject_id");
});
modelBuilder.Entity("Tiku.Domain.Content.VideoPlaybackProgress", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_video_playback_progress_tenants_tenant_id");
b.HasOne("Tiku.Domain.Identity.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_video_playback_progress_users_user_id");
b.HasOne("Tiku.Domain.QuestionBanks.Question", null)
.WithMany()
.HasForeignKey("TenantId", "QuestionId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_video_playback_progress_questions_tenant_id_question_id");
b.HasOne("Tiku.Domain.Content.VideoExplanation", null)
.WithMany()
.HasForeignKey("TenantId", "VideoId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_video_playback_progress_video_explanations_tenant_id_video_~");
});
modelBuilder.Entity("Tiku.Domain.Content.VocabularyUnit", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)

View File

@@ -98,6 +98,7 @@ public sealed class TikuDbContext(
public DbSet<AppAsset> AppAssets => Set<AppAsset>();
public DbSet<VideoExplanation> VideoExplanations => Set<VideoExplanation>();
public DbSet<QuestionVideo> QuestionVideos => Set<QuestionVideo>();
public DbSet<VideoPlaybackProgress> VideoPlaybackProgress => Set<VideoPlaybackProgress>();
public DbSet<TenantQuestionBankPreference> TenantQuestionBankPreferences => Set<TenantQuestionBankPreference>();
public DbSet<TenantQuestionReference> TenantQuestionReferences => Set<TenantQuestionReference>();
public DbSet<AiRecommendationReport> AiRecommendationReports => Set<AiRecommendationReport>();

View File

@@ -4,6 +4,7 @@ using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Points;
using Tiku.Domain.Commerce;
using Tiku.Domain.Learning;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -125,6 +126,25 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService
Metadata = JsonSerializer.SerializeToElement(new { source = "student_points" })
};
dbContext.PointActivityClaims.Add(claim);
var balanceAfter = (await GetSummaryCoreAsync(actor, cancellationToken)).BalancePoints + task.Points;
dbContext.UserScoreEvents.Add(new UserScoreEvent
{
TenantId = actor.TenantId,
UserId = actor.UserId,
EventType = task.TaskType == PointActivityTaskType.DailyLogin
? UserScoreEventType.CheckIn
: UserScoreEventType.ActivityReward,
Points = task.Points,
BalanceAfter = balanceAfter,
SourceType = NormalizeOptional(command.SourceType) ?? "point_task",
SourceId = command.SourceId ?? claim.Id,
IdempotencyKey = $"point-claim:{actor.TenantId:N}:{claim.Id:N}",
Metadata = JsonSerializer.SerializeToElement(new
{
task.Id,
task.TaskKey
})
});
await dbContext.SaveChangesAsync(cancellationToken);
return ToClaimItem(claim);
}
@@ -212,6 +232,23 @@ public sealed class PointService(TikuDbContext dbContext) : IPointService
Metadata = JsonSerializer.SerializeToElement(new { source = "student_points_exchange" })
};
dbContext.PointExchangeOrders.Add(order);
dbContext.UserScoreEvents.Add(new UserScoreEvent
{
TenantId = actor.TenantId,
UserId = actor.UserId,
EventType = UserScoreEventType.RedeemCost,
Points = -item.PointsCost,
BalanceAfter = balance - item.PointsCost,
SourceType = "point_exchange_order",
SourceId = order.Id,
IdempotencyKey = $"point-exchange:{actor.TenantId:N}:{order.Id:N}",
Metadata = JsonSerializer.SerializeToElement(new
{
item.Id,
item.ItemKey,
item.Name
})
});
if (item.ItemType == PointExchangeItemType.Entitlement)
{
await GrantEntitlementAsync(actor, item, order, now, cancellationToken);

View File

@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Profile;
using Tiku.Domain.Catalog;
@@ -292,6 +293,133 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
return ToFeedbackItem(feedback);
}
public async Task<CheckInResult> CheckInAsync(
ProfileActor actor,
CancellationToken cancellationToken = default)
{
var profile = await EnsureProfileAsync(actor, cancellationToken);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var sourceId = CreateDeterministicGuid($"{actor.TenantId:N}:{actor.UserId:N}:check-in:{today:yyyyMMdd}");
var idempotencyKey = $"check-in:{actor.TenantId:N}:{actor.UserId:N}:{today:yyyyMMdd}";
var existingEvent = await dbContext.UserScoreEvents.AsNoTracking()
.SingleOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.IdempotencyKey == idempotencyKey,
cancellationToken);
if (existingEvent is not null)
{
var existingClaim = await dbContext.PointActivityClaims.AsNoTracking()
.SingleOrDefaultAsync(
item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.SourceType == "daily_check_in" &&
item.SourceId == sourceId,
cancellationToken);
return new CheckInResult(
today,
true,
existingEvent.Points,
existingEvent.BalanceAfter,
existingClaim?.Id,
existingEvent.Id);
}
var task = await dbContext.PointActivityTasks
.Where(item =>
item.TenantId == actor.TenantId &&
item.Status == PointActivityTaskStatus.Active &&
(item.TaskKey == "daily_check_in" || item.TaskKey == "daily_login"))
.OrderByDescending(item => item.TaskKey == "daily_check_in")
.ThenBy(item => item.SortOrder)
.FirstOrDefaultAsync(cancellationToken)
?? throw new ProfileException("Daily check-in point task was not configured.", "check_in_task_not_found");
var now = DateTimeOffset.UtcNow;
var claim = new PointActivityClaim
{
TenantId = actor.TenantId,
TaskId = task.Id,
TaskKey = task.TaskKey,
UserId = actor.UserId,
Points = task.Points,
Status = PointActivityClaimStatus.Claimed,
SourceType = "daily_check_in",
SourceId = sourceId,
ClaimedAt = now,
Metadata = JsonSerializer.SerializeToElement(new
{
checkInDate = today,
source = "profile_check_in"
})
};
dbContext.PointActivityClaims.Add(claim);
var balanceAfter = await CalculatePointBalanceAsync(actor, cancellationToken) + task.Points;
var scoreEvent = new UserScoreEvent
{
TenantId = actor.TenantId,
UserId = actor.UserId,
EventType = UserScoreEventType.CheckIn,
Points = task.Points,
BalanceAfter = balanceAfter,
SourceType = "daily_check_in",
SourceId = sourceId,
IdempotencyKey = idempotencyKey,
Metadata = JsonSerializer.SerializeToElement(new
{
task.Id,
task.TaskKey,
checkInDate = today
}),
CreatedAt = now
};
dbContext.UserScoreEvents.Add(scoreEvent);
profile.LastCheckInDate = today;
await dbContext.SaveChangesAsync(cancellationToken);
return new CheckInResult(today, false, task.Points, balanceAfter, claim.Id, scoreEvent.Id);
}
public async Task<ProfileScoreEventList> GetScoreEventsAsync(
ProfileActor actor,
ProfileScoreEventQuery query,
CancellationToken cancellationToken = default)
{
await EnsureProfileAsync(actor, cancellationToken);
var events = dbContext.UserScoreEvents.AsNoTracking()
.Where(item => item.TenantId == actor.TenantId && item.UserId == actor.UserId);
if (!string.IsNullOrWhiteSpace(query.SourceType))
{
var sourceType = query.SourceType.Trim();
events = events.Where(item => item.SourceType == sourceType);
}
if (query.From.HasValue)
{
events = events.Where(item => item.CreatedAt >= query.From.Value);
}
if (query.To.HasValue)
{
events = events.Where(item => item.CreatedAt <= query.To.Value);
}
var items = await events
.OrderByDescending(item => item.CreatedAt)
.Take(Math.Clamp(query.Limit ?? 50, 1, 200))
.Select(item => new ProfileScoreEventItem(
item.Id,
ToWire(item.EventType),
item.Points,
item.BalanceAfter,
item.SourceType,
item.SourceId,
item.Metadata,
item.CreatedAt))
.ToArrayAsync(cancellationToken);
return new ProfileScoreEventList(items);
}
private async Task<StudentProfile> EnsureProfileAsync(
ProfileActor actor,
CancellationToken cancellationToken)
@@ -525,6 +653,31 @@ public sealed class ProfileService(TikuDbContext dbContext) : IProfileService
? "_" + char.ToLowerInvariant(ch)
: char.ToLowerInvariant(ch).ToString()));
}
private async Task<int> CalculatePointBalanceAsync(ProfileActor actor, CancellationToken cancellationToken)
{
var earned = await dbContext.PointActivityClaims.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == PointActivityClaimStatus.Claimed)
.SumAsync(item => (int?)item.Points, cancellationToken) ?? 0;
var spent = await dbContext.PointExchangeOrders.AsNoTracking()
.Where(item =>
item.TenantId == actor.TenantId &&
item.UserId == actor.UserId &&
item.Status == PointExchangeOrderStatus.Completed)
.SumAsync(item => (int?)item.PointsCost, cancellationToken) ?? 0;
return earned - spent;
}
private static Guid CreateDeterministicGuid(string value)
{
var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value));
Span<byte> guidBytes = stackalloc byte[16];
bytes.AsSpan(0, 16).CopyTo(guidBytes);
return new Guid(guidBytes);
}
}
public sealed class ProfileException(string message, string code) : Exception(message)

View File

@@ -1,12 +1,15 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.Jobs;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
@@ -16,6 +19,11 @@ namespace Tiku.IntegrationTests.Api;
public sealed class DirectContentEndpointTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
[Fact]
public async Task Tenant_admin_can_create_question_and_sync_primary_collection()
{
@@ -175,6 +183,44 @@ public sealed class DirectContentEndpointTests
Assert.True(dbContext.Questions.Single(item => item.Id == questionId).HasVideoExplanation);
}
[Fact]
public async Task Tenant_admin_can_queue_content_import_and_worker_writes_import_detail()
{
await using var factory = new ApiTestFactory();
var seed = await SeedAdminAsync(factory);
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var queueResponse = await client.PostAsJsonAsync(
"/api/tenant-content/imports/questions",
new DirectImportDto
{
Async = true,
Items =
[
JsonSerializer.SerializeToElement(new { type = "choice", content = "异步导入题" })
]
});
var queuedJob = await queueResponse.Content.ReadFromJsonAsync<BackgroundJobItem>(JsonOptions);
using var scope = factory.CreateSystemScope();
var jobService = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
var processed = await jobService.ProcessPendingAsync("content-import-test-worker", 10);
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var storedJob = dbContext.BackgroundJobs.Single(item => item.Id == queuedJob!.Id);
var importJobId = storedJob.Result.GetProperty("importJobId").GetGuid();
var detailResponse = await client.GetAsync($"/api/tenant-content/imports/detail?jobId={importJobId}");
var detail = await ReadJsonAsync(detailResponse);
Assert.Equal(HttpStatusCode.Accepted, queueResponse.StatusCode);
Assert.Equal(1, processed);
Assert.Equal(BackgroundJobStatus.Succeeded, storedJob.Status);
Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
Assert.Equal(1, detail.RootElement.GetProperty("job").GetProperty("insertedCount").GetInt32());
Assert.Single(detail.RootElement.GetProperty("items").EnumerateArray());
}
[Fact]
public async Task Tenant_admin_can_upsert_scoreline_fields_and_records()
{

View File

@@ -1,10 +1,13 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.Profile;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Commerce;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
@@ -16,6 +19,11 @@ namespace Tiku.IntegrationTests.Api;
public sealed class ProfileEndpointTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
[Fact]
public async Task Logged_in_student_can_get_and_update_profile()
{
@@ -189,6 +197,59 @@ public sealed class ProfileEndpointTests
Assert.Single(dbContext.ReportStatusEvents);
}
[Fact]
public async Task Student_can_check_in_once_per_day_and_query_score_events()
{
await using var factory = new ApiTestFactory();
var seed = await SeedStudentAsync(factory);
await factory.SeedAsync(
new StudentProfile
{
TenantId = seed.TenantId,
UserId = seed.UserId
},
new PointActivityTask
{
TenantId = seed.TenantId,
TaskKey = "daily_check_in",
Title = "每日签到",
TaskType = PointActivityTaskType.DailyLogin,
Points = 5,
MaxClaimsPerUser = 3650
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var firstResponse = await client.PostAsync("/api/profile/check-in", null);
var first = await firstResponse.Content.ReadFromJsonAsync<CheckInResult>(JsonOptions);
var secondResponse = await client.PostAsync("/api/profile/check-in", null);
var second = await secondResponse.Content.ReadFromJsonAsync<CheckInResult>(JsonOptions);
var eventsResponse = await client.GetAsync("/api/profile/score-events?sourceType=daily_check_in");
var events = await eventsResponse.Content.ReadFromJsonAsync<ProfileScoreEventList>(JsonOptions);
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
Assert.False(first!.AlreadyCheckedIn);
Assert.Equal(5, first.Points);
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
Assert.True(second!.AlreadyCheckedIn);
Assert.Equal(first.ScoreEventId, second.ScoreEventId);
Assert.Equal(HttpStatusCode.OK, eventsResponse.StatusCode);
var item = Assert.Single(events!.Items);
Assert.Equal("check_in", item.EventType);
Assert.Equal(5, item.Points);
using var scope = factory.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Single(dbContext.PointActivityClaims.Where(claim =>
claim.TenantId == seed.TenantId &&
claim.UserId == seed.UserId &&
claim.SourceType == "daily_check_in"));
Assert.Single(dbContext.UserScoreEvents.Where(scoreEvent =>
scoreEvent.TenantId == seed.TenantId &&
scoreEvent.UserId == seed.UserId &&
scoreEvent.SourceType == "daily_check_in"));
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedStudentAsync(ApiTestFactory factory)
{
var tenantId = Guid.NewGuid();

View File

@@ -0,0 +1,161 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.Assets;
using Tiku.Application.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Auth;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class VideoEndpointTests
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
[Fact]
public async Task Student_can_search_play_report_progress_and_query_question_videos()
{
await using var factory = new ApiTestFactory();
var seed = await SeedStudentAsync(factory);
var questionId = Guid.NewGuid();
var videoId = Guid.NewGuid();
await factory.SeedAsync(
new Question { Id = questionId, TenantId = seed.TenantId, Type = "choice", Status = QuestionStatus.Published },
new VideoExplanation
{
Id = videoId,
TenantId = seed.TenantId,
Title = "透视解析",
Description = "素描透视",
VideoUrl = "https://cdn.example.test/video.mp4",
DurationSeconds = 120,
IsActive = true
},
new QuestionVideo
{
TenantId = seed.TenantId,
QuestionId = questionId,
VideoId = videoId
});
using var client = factory.CreateClient();
await LoginAsync(client, seed);
var search = await client.GetFromJsonAsync<CatalogList<VideoExplanationCatalogItem>>("/api/videos/search?keyword=透视", JsonOptions);
var questionVideos = await client.GetFromJsonAsync<CatalogList<QuestionVideoCatalogItem>>($"/api/questions/videos?questionId={questionId}", JsonOptions);
var batch = await (await client.PostAsJsonAsync(
"/api/questions/videos/batch",
new QuestionVideoQueryDto { QuestionIds = [questionId] }))
.Content
.ReadFromJsonAsync<CatalogList<QuestionVideoCatalogItem>>(JsonOptions);
var playResponse = await client.PostAsJsonAsync(
"/api/videos/play",
new VideoPlayDto { VideoId = videoId, QuestionId = questionId });
var play = await playResponse.Content.ReadFromJsonAsync<VideoPlaybackItem>(JsonOptions);
var progressResponse = await client.PostAsJsonAsync(
"/api/videos/progress",
new VideoProgressDto
{
VideoId = videoId,
QuestionId = questionId,
PositionSeconds = 120,
DurationSeconds = 120,
WatchedSeconds = 120
});
var progress = await progressResponse.Content.ReadFromJsonAsync<VideoProgressItem>(JsonOptions);
Assert.Single(search!.Items);
Assert.Single(questionVideos!.Items);
Assert.Single(batch!.Items);
Assert.Equal(HttpStatusCode.OK, playResponse.StatusCode);
Assert.Equal("https://cdn.example.test/video.mp4", play!.PlayUrl);
Assert.Equal(HttpStatusCode.OK, progressResponse.StatusCode);
Assert.True(progress!.IsCompleted);
using var scope = factory.CreateSystemScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Single(dbContext.VideoPlaybackProgress.Where(item =>
item.TenantId == seed.TenantId &&
item.UserId == seed.UserId &&
item.VideoId == videoId &&
item.QuestionId == questionId));
Assert.Single(dbContext.ContentAssetAccessEvents.Where(item =>
item.TenantId == seed.TenantId &&
item.UserId == seed.UserId &&
item.AssetType == "Video"));
}
[Fact]
public async Task Student_cannot_play_another_tenant_video()
{
await using var factory = new ApiTestFactory();
var tenantA = await SeedStudentAsync(factory, "13900001001");
var tenantB = await SeedStudentAsync(factory, "13900001002");
var videoId = Guid.NewGuid();
await factory.SeedAsync(new VideoExplanation
{
Id = videoId,
TenantId = tenantA.TenantId,
Title = "A tenant video",
VideoUrl = "https://cdn.example.test/a.mp4",
IsActive = true
});
using var client = factory.CreateClient();
await LoginAsync(client, tenantB);
var response = await client.PostAsJsonAsync(
"/api/videos/play",
new VideoPlayDto { VideoId = videoId });
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private static async Task<(Guid TenantId, Guid UserId, string Phone)> SeedStudentAsync(
ApiTestFactory factory,
string? phone = null)
{
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var resolvedPhone = phone ?? $"135{Random.Shared.Next(10000000, 99999999)}";
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Video Tenant",
Status = TenantStatus.Active,
Metadata = JsonDefaults.Object()
},
new User
{
Id = userId,
Phone = resolvedPhone,
Name = "Video Student"
}.WithTestPassword(),
new TenantMembership
{
TenantId = tenantId,
UserId = userId,
Role = TenantRole.Student,
Status = MembershipStatus.Active
});
return (tenantId, userId, resolvedPhone);
}
private static async Task LoginAsync(
HttpClient client,
(Guid TenantId, Guid UserId, string Phone) seed)
{
client.UseAccessToken(await client.LoginAsTenantAsync(seed.TenantId, seed.Phone));
}
}

View File

@@ -265,7 +265,8 @@ public sealed class ArchitectureBoundaryTests
"Aliyun.OSS",
"Senparc.Weixin",
"AlipaySDKNet",
"Aop.Api"
"Aop.Api",
"Microsoft.SemanticKernel"
};
var violations = sourceRoots

View File

@@ -10,6 +10,7 @@
- [`docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md`](migration/phase-3-tenant-isolation-and-shared-question-bank.md)
- [`docs/migration/phase-4-external-provider-decoupling.md`](migration/phase-4-external-provider-decoupling.md)
- [`docs/migration/phase-5-backoffice-worker-operations.md`](migration/phase-5-backoffice-worker-operations.md)
- [`docs/migration/phase-7-student-experience-and-content-consumption.md`](migration/phase-7-student-experience-and-content-consumption.md)
迁移原则:
@@ -65,9 +66,10 @@
- 公开 catalogBanner、FAQ、公告、考试日期、商品、SVIP 套餐等。
- 内容导航、题库、词汇、手册、视频、资源公开查询。
- 资源上传签名、确认、下载/预览签名。
- 学生个人中心、通知、徽章、反馈
- 学生端视频搜索、播放授权、观看进度、题目解析视频查询
- 学生个人中心、签到、积分流水、通知、徽章、反馈。
- 学习统计、排行榜、错题/单词复习。
- 题目管理、内容导入骨架、词汇/手册/分数线/视频后台管理。
- 题目管理、内容导入预览、同步导入、异步导入任务、导入详情、词汇/手册/分数线/视频后台管理。
- 商品下单、微信/支付宝/manual 支付、支付回调、权益发放。
- 积分任务、积分领取、积分兑换。
- 优惠券领取、校验、下单抵扣、零元订单发权益。
@@ -78,106 +80,9 @@
## 剩余迁移范围
剩余部分不建议再按“旧接口数量”机械推进。旧 NestJS 里还有大量平台运营、自动化、审计、对账、AI 推荐等后段能力,应该按业务风险和依赖关系分批迁移
剩余部分不建议再按“旧接口数量”机械推进。第六阶段已经把平台后台、租户后台、交易运营和 Worker 最小闭环打通;第七阶段补齐了学生端视频、签到积分流水和内容导入异步化。后续应按“能上线运营”和“体验增强”拆分
### 1. 高级交易运营
建议下一批优先迁移。
旧版相关模块:
- `commerce-adjustments.module.ts`
- `commerce-reconciliation.module.ts`
- 部分 `commerce-payments.module.ts`
目标能力:
- 退款申请、审核、处理、退款事件。
- 支付/订单调账凭证。
- 对账批次、对账明细、对账异常。
- 对账问题创建、分配、状态流转、处理事件。
- provider bill jobs 账单下载任务骨架。
- 管理侧交易异常报表。
为什么优先:
- 当前系统已经能下单、支付、发权益、算佣金。
- 真实运营里最先遇到的是退款、支付差异、人工调账。
- 相关数据库模型基本已经存在,适合继续在交易边界内补完整。
建议提交拆分:
1. `feat: add commerce refund operations`
2. `feat: add commerce reconciliation queries`
3. `feat: add commerce reconciliation issue workflow`
4. `feat: add commerce adjustment voucher operations`
### 2. 租户内容导出与 Worker 骨架
旧版相关模块:
- `tenant-content-exports.module.ts`
- 内容导入/统计/资源扫描相关 worker 逻辑。
目标能力:
- 内容导出任务查询。
- 创建导出任务。
- 导出文件落 OSS。
- 导入任务异步化。
- 资源安全扫描任务骨架。
- 统计聚合 worker 骨架。
注意:
- 不要把 worker 和 API 写成一坨。
- API 只负责创建任务、查询状态、下载结果。
- Worker 独立处理重试、幂等、失败记录。
### 3. 平台后台基础
旧版相关模块:
- `platform-admin-overview.module.ts`
- `platform-admin-tenants.module.ts`
- `platform-admin-question-banks.module.ts`
目标能力:
- 平台总览。
- 平台权限摘要。
- 平台员工查询与状态管理。
- 租户列表、租户状态调整。
- 租户账单资料。
- 公库题库授权/采纳管理。
注意:
- 这块是 SaaS 平台运营,不是学生端主链路。
- 需要先明确平台管理员权限模型,避免继续用粗粒度 `TenantAdmin`
### 4. 平台账单、催缴、审计告警
旧版相关模块:
- `platform-admin-billing.module.ts`
- `platform-admin-dunning.module.ts`
- `platform-admin-audit.module.ts`
目标能力:
- SaaS 套餐和租户订阅。
- 用量记录与账单生成。
- 发票、发票明细、收款记录。
- 催缴提醒与通知渠道。
- 审计告警规则、告警查询、确认、解决。
注意:
- 这块涉及平台财务和自动化,不建议和学生交易混在一起。
- 自动生成账单、催缴、告警扫描应放到 Worker。
### 5. AI 推荐报告
### 1. AI 推荐报告,独立阶段
旧版相关模块:
@@ -189,33 +94,67 @@
- 推荐详情。
- 推荐报告导出。
- 推荐生成任务。
- 租户级 AI Provider 配置、API Key 密钥托管和调用审计。
注意
阶段约定
- 不建议直接照搬旧版
- 需要结合分数线动态字段、用户画像、目标院校、志愿规则重新设计
- 应等核心数据和 worker 稳定后再做
- 默认基于 Microsoft Semantic Kernel 设计 Kernel / Plugin / AI Service 编排
- AI Provider 使用 `TenantExternalProvider(capability=ai)`
- 租户 API Key 存入 `TenantSecret`,不进入业务 DTO
- `Microsoft.SemanticKernel` 只允许出现在 Infrastructure AI provider 实现中。
- Application 层只暴露业务抽象,例如 `IAiRecommendationProvider` / `IAiKernelFactory`
- 不直接照搬旧 prompt 或推荐算法,先结合分数线动态字段、用户画像、目标院校和志愿规则重新设计。
### 6. 零散补齐项
### 2. 内容导出和导入处理器增强
这些可以穿插迁移,但不建议打断主线
当前已经支持导入预览、同步小批量导入、异步 `content_import` job 和导入详情查询。后续增强
- 内容导出任务查询和创建。
- 题库、题目、学生数据导出到对象存储。
- 导入 preview/result/issue 更细化。
- 大文件导入解析进度、失败行回放和重试。
- 导入、导出输出资产统一走对象存储 provider不暴露 bucket/key。
### 3. Worker 处理器补强
当前 Worker 已有统一任务模型、租户 scope 和部分实处理器。后续增强:
- `content_export` 完整导出。
- `asset_security_scan` 接真实扫描 provider。
- `statistics_aggregation` 增量聚合。
- `commerce_reconciliation` 接真实 provider bill downloader。
- `tenant_domain_recheck` 增加周期调度和告警联动。
### 4. 后台运营细化
平台后台、租户后台和交易运营已有主线能力,后续按运营优先级补:
- 学生端视频搜索、视频观看进度。
- 租户 secrets 通用后台管理。
- 租户监督规则。
- 租户监督规则和跟进报表细化
- 租户洞察报表。
- 更细粒度 RBAC 权限点。
- 管理后台操作审计补全
- 管理后台操作审计覆盖率补齐
- 发票、催缴、佣金联动调账。
### 5. 旧路径兼容评估
默认不迁旧 URL只迁行为能力。只有前端明确依赖且重写成本高时才增加薄兼容 Controller兼容层不得恢复旧 Supabase、旧 grant/adoption 公共题库授权模型、旧 `QuestionIds` JSON 或旧 Provider 配置表。
明确不再迁移为主接口:
- `/api/profile/activity-tasks`,由 `/api/points/tasks` 替代。
- `/api/profile/exchange-items``/api/profile/exchange-items/redeem`,由 `/api/points/exchange-items``/api/points/exchange-orders` 替代。
- 公共题库 adopt/sync/grant已被平台公共题库所有权 + `TenantQuestionReference` 模型替代。
- Supabase 相关任何路径。
## 推荐后续顺序
```text
1. 高级交易运营:退款、对账、调账
2. 租户内容导出 + Worker 骨架
3. 平台后台基础:总览、租户、平台员工、公库授权
4. 平台账单/发票/催缴/审计告警
5. AI 推荐报告
6. 零散体验与后台增强
1. AI 推荐报告独立阶段Semantic Kernel + 租户自带 API Key
2. 内容导出 + 导入处理器增强
3. Worker 统计、扫描、对账、域名复验处理器补强
4. 后台运营细化secrets、监督、洞察、审计、发票/催缴
5. 按前端实际依赖做旧路径兼容评估
```
## 每批固定验收
@@ -225,36 +164,32 @@
```bash
dotnet restore TIKU-BACKEND.slnx
dotnet build TIKU-BACKEND.slnx --no-restore
dotnet test TIKU-BACKEND.slnx --no-build
dotnet format TIKU-BACKEND.slnx --verify-no-changes --no-restore
dotnet test Tiku.UnitTests/Tiku.UnitTests.csproj --no-build
dotnet test Tiku.IntegrationTests/Tiku.IntegrationTests.csproj --no-build
git diff --check
dotnet ef migrations script \
--project Tiku.Infrastructure \
--startup-project Tiku.DbMigrator
dotnet ef migrations has-pending-model-changes \
--project Tiku.Infrastructure \
--startup-project Tiku.DbMigrator
```
如果某批不涉及数据库 migration也仍然运行 migration script确认当前模型快照和迁移链没有损坏。
## 下一批建议
下一批建议执行:
下一批建议执行独立 AI 阶段,但先只做架构底座和一个最小推荐报告闭环,不直接接生产模型
```text
高级交易运营:退款、对账、调账
```
建议目标:
- 学生/管理员可查询退款状态。
- 租户管理员可创建、审核、处理退款申请。
- 支付回调和退款事件保持幂等。
- 对账批次、明细、异常问题可查询。
- 对账异常可创建 issue、流转状态、记录事件。
- 调账凭证可创建、审核、作废,并影响后续报表口径。
- 增加 `TenantExternalProviderCapability.Ai` 和密钥配置校验。
- 增加 Application AI 抽象,不让 Controller 直接接触 SK。
- Infrastructure 引入 Semantic Kernel provider实现 fake/local stub 和真实 provider 边界。
- 设计推荐报告数据模型、任务模型、调用审计和成本记录。
- 完成报告生成任务、列表、详情和导出骨架。
建议暂缓:
- 自动下载真实微信/支付宝账单
- 自动退款真实网关请求
- 平台级财务审批流
- 发票、催缴、佣金联动调账
- 复杂 RAG
- 自动志愿填报决策
- 多模型路由优化
- 生产 API Key 托管 UI 之外的手工配置方案

View File

@@ -1,8 +1,8 @@
method,path,status,legacy_operation_id,legacy_summary,target_operation_id,target_summary
GET,/api/ai/school-recommendations,legacy_only,AiController_list,查询院校推荐报告,,
GET,/api/ai/school-recommendations/detail,legacy_only,AiController_detail,获取院校推荐报告详情,,
GET,/api/ai/school-recommendations/export,legacy_only,AiController_exportReport,导出院校推荐报告,,
POST,/api/ai/school-recommendations/generate,legacy_only,AiController_generate,生成院校推荐报告,,
GET,/api/ai/school-recommendations,legacy_only,AiController_list,查询院校推荐报告,,AI 延后到 Semantic Kernel + 租户自带 API Key 独立阶段
GET,/api/ai/school-recommendations/detail,legacy_only,AiController_detail,获取院校推荐报告详情,,AI 延后到 Semantic Kernel + 租户自带 API Key 独立阶段
GET,/api/ai/school-recommendations/export,legacy_only,AiController_exportReport,导出院校推荐报告,,AI 延后到 Semantic Kernel + 租户自带 API Key 独立阶段
POST,/api/ai/school-recommendations/generate,legacy_only,AiController_generate,生成院校推荐报告,,AI 延后到 Semantic Kernel + 租户自带 API Key 独立阶段
GET,/api/assets/{assetId}/download,target_only,,,,获取资源下载地址
GET,/api/assets/{assetId}/preview,target_only,,,,获取资源预览地址
POST,/api/auth/login/password,target_only,,,,手机号密码登录
@@ -181,22 +181,22 @@ POST,/api/points/exchange-orders,target_only,,,,创建积分兑换订单
GET,/api/points/summary,target_only,,,,查询当前用户积分摘要
GET,/api/points/tasks,target_only,,,,查询当前可领取积分任务
POST,/api/points/tasks/claim,target_only,,,,领取积分任务奖励
GET,/api/profile/activity-tasks,legacy_only,ProfileController_tasks,查询积分活动任务,,
POST,/api/profile/activity-tasks/claim,legacy_only,ProfileController_claimTask,领取活动任务奖励,,
GET,/api/profile/activity-tasks,legacy_only,ProfileController_tasks,查询积分活动任务,,由 /api/points/tasks 替代
POST,/api/profile/activity-tasks/claim,legacy_only,ProfileController_claimTask,领取活动任务奖励,,由 /api/points/tasks/claim 替代
GET,/api/profile/badges,exact_match,ProfileController_badges,查询徽章列表,,查询徽章列表
POST,/api/profile/check-in,legacy_only,ProfileController_checkIn,每日签到,,
POST,/api/profile/check-in,exact_match,ProfileController_checkIn,每日签到,,每日签到
GET,/api/profile/exam-countdowns,exact_match,ProfileController_countdowns,查询考试倒计时,,查询考试倒计时
GET,/api/profile/exchange-items,legacy_only,ProfileController_exchangeItems,查询积分兑换商品,,
POST,/api/profile/exchange-items/redeem,legacy_only,ProfileController_redeem,兑换积分商品,,
GET,/api/profile/exchange-items,legacy_only,ProfileController_exchangeItems,查询积分兑换商品,,由 /api/points/exchange-items 替代
POST,/api/profile/exchange-items/redeem,legacy_only,ProfileController_redeem,兑换积分商品,,由 /api/points/exchange-orders 替代
GET,/api/profile/feedbacks,exact_match,ProfileController_feedbacks,查询反馈记录,,查询反馈记录
POST,/api/profile/feedbacks,exact_match,ProfileController_submitFeedback,提交意见反馈,,提交意见反馈
GET,/api/profile/me,exact_match,ProfileController_me,获取当前学生资料,,获取当前学生资料
PATCH,/api/profile/me,exact_match,ProfileController_updateMe,更新当前学生资料,,更新当前学生资料
GET,/api/profile/notifications,exact_match,ProfileController_notificationList,查询用户通知,,查询用户通知
POST,/api/profile/notifications/status,exact_match,ProfileController_notificationStatus,更新通知状态,,更新通知状态
GET,/api/profile/score-events,legacy_only,ProfileController_scoreEvents,查询积分流水,,
GET,/api/questions/videos,legacy_only,QuestionVideoController_list,查询单道题目的解析视频,,
POST,/api/questions/videos/batch,legacy_only,QuestionVideoController_batch,批量查询题目解析视频,,
GET,/api/profile/score-events,exact_match,ProfileController_scoreEvents,查询积分流水,,查询当前用户积分流水
GET,/api/questions/videos,exact_match,QuestionVideoController_list,查询单道题目的解析视频,,查询单道题目的解析视频
POST,/api/questions/videos/batch,exact_match,QuestionVideoController_batch,批量查询题目解析视频,,批量查询题目解析视频
POST,/api/referral/bind,exact_match,ReferralPublicController_bind,绑定当前用户的推荐归属,,绑定当前用户推荐归属
GET,/api/referral/conversion-report,exact_match,ReferralManagementController_conversion,查询推荐转化与佣金报告,,查询推荐转化报告
POST,/api/referral/invite-code,exact_match,ReferralPublicController_invite,生成或查询当前成员邀请码,,生成或查询当前成员邀请码
@@ -346,24 +346,24 @@ PUT,/api/tenant-content/handbook-subjects,exact_match,HandbookManagementControll
GET,/api/tenant-content/import-jobs,target_only,,,,查询内容导入任务
GET,/api/tenant-content/import-jobs/{jobId},target_only,,,,查询内容导入任务详情
GET,/api/tenant-content/imports,legacy_only,TenantContentImportsController_jobs,查询内容导入任务,,
GET,/api/tenant-content/imports/detail,legacy_only,TenantContentImportsController_detail,查询内容导入任务详情,,
GET,/api/tenant-content/imports/detail,exact_match,TenantContentImportsController_detail,查询内容导入任务详情,,查询内容导入任务详情
GET,/api/tenant-content/imports/field-mapping,exact_match,TenantContentImportsController_fieldMapping,查询导入字段映射说明,,查询导入字段映射
POST,/api/tenant-content/imports/handbook,legacy_only,TenantContentImportsController_importHandbook,执行或排队知识手册导入,,
POST,/api/tenant-content/imports/handbook,legacy_only,TenantContentImportsController_importHandbook,执行或排队知识手册导入,,由 /api/tenant-content/imports/{importType} 替代
GET,/api/tenant-content/imports/issues,exact_match,TenantContentImportsController_issues,查询内容导入问题明细,,查询内容导入问题明细
GET,/api/tenant-content/imports/post-check,exact_match,TenantContentImportsController_postCheckStatus,查询内容导入后检查状态,,查询内容导入后检查状态
POST,/api/tenant-content/imports/post-check,exact_match,TenantContentImportsController_runPostCheck,执行内容导入后完整性检查,,执行内容导入后完整性检查
POST,/api/tenant-content/imports/preview/handbook,legacy_only,TenantContentImportsController_previewHandbook,预览知识手册导入数据,,
POST,/api/tenant-content/imports/preview/questions,legacy_only,TenantContentImportsController_previewQuestions,预览题目导入数据,,
POST,/api/tenant-content/imports/preview/scoreline,legacy_only,TenantContentImportsController_previewScoreline,预览分数线导入数据,,
POST,/api/tenant-content/imports/preview/videos,legacy_only,TenantContentImportsController_previewVideos,预览视频解析导入数据,,
POST,/api/tenant-content/imports/preview/vocabulary,legacy_only,TenantContentImportsController_previewVocabulary,预览词汇导入数据,,
POST,/api/tenant-content/imports/preview/handbook,legacy_only,TenantContentImportsController_previewHandbook,预览知识手册导入数据,,由 /api/tenant-content/imports/preview/{importType} 替代
POST,/api/tenant-content/imports/preview/questions,legacy_only,TenantContentImportsController_previewQuestions,预览题目导入数据,,由 /api/tenant-content/imports/preview/{importType} 替代
POST,/api/tenant-content/imports/preview/scoreline,legacy_only,TenantContentImportsController_previewScoreline,预览分数线导入数据,,由 /api/tenant-content/imports/preview/{importType} 替代
POST,/api/tenant-content/imports/preview/videos,legacy_only,TenantContentImportsController_previewVideos,预览视频解析导入数据,,由 /api/tenant-content/imports/preview/{importType} 替代
POST,/api/tenant-content/imports/preview/vocabulary,legacy_only,TenantContentImportsController_previewVocabulary,预览词汇导入数据,,由 /api/tenant-content/imports/preview/{importType} 替代
POST,/api/tenant-content/imports/preview/{importType},target_only,,,,预览内容导入数据
POST,/api/tenant-content/imports/questions,legacy_only,TenantContentImportsController_importQuestions,执行或排队题目导入,,
POST,/api/tenant-content/imports/scoreline,legacy_only,TenantContentImportsController_importScoreline,执行或排队分数线导入,,
POST,/api/tenant-content/imports/questions,legacy_only,TenantContentImportsController_importQuestions,执行或排队题目导入,,由 /api/tenant-content/imports/{importType} 替代
POST,/api/tenant-content/imports/scoreline,legacy_only,TenantContentImportsController_importScoreline,执行或排队分数线导入,,由 /api/tenant-content/imports/{importType} 替代
GET,/api/tenant-content/imports/templates,exact_match,TenantContentImportsController_template,获取内容导入模板,,获取内容导入模板
POST,/api/tenant-content/imports/videos,legacy_only,TenantContentImportsController_importVideos,执行或排队视频解析导入,,
POST,/api/tenant-content/imports/vocabulary,legacy_only,TenantContentImportsController_importVocabulary,执行或排队词汇导入,,
POST,/api/tenant-content/imports/{importType},target_only,,,,执行同步内容导入
POST,/api/tenant-content/imports/videos,legacy_only,TenantContentImportsController_importVideos,执行或排队视频解析导入,,由 /api/tenant-content/imports/{importType} 替代
POST,/api/tenant-content/imports/vocabulary,legacy_only,TenantContentImportsController_importVocabulary,执行或排队词汇导入,,由 /api/tenant-content/imports/{importType} 替代
POST,/api/tenant-content/imports/{importType},target_only,,,,执行同步或异步内容导入
GET,/api/tenant-content/media-analytics/asset-events,legacy_only,TenantContentAssetsController_assetAnalytics,查询媒体资产访问明细,,
GET,/api/tenant-content/media-analytics/summary,legacy_only,TenantContentAssetsController_summary,查询媒体访问分析汇总,,
GET,/api/tenant-content/media-analytics/video-events,legacy_only,TenantContentAssetsController_videoAnalytics,查询媒体视频播放明细,,
@@ -409,7 +409,7 @@ PUT,/api/tenant-content/vocabulary-words,exact_match,VocabularyManagementControl
GET,/api/tenant/current-public,target_only,,,,获取公开租户配置
GET,/api/tenant/resolve,exact_match,TenantController_resolve,解析当前租户,,解析当前租户
GET,/api/tenants/current,target_only,,,,
POST,/api/videos/play,legacy_only,VideoController_play,申请视频播放地址,,
POST,/api/videos/progress,legacy_only,VideoController_progress,上报视频播放进度,,
GET,/api/videos/search,legacy_only,VideoController_search,搜索通用解析视频,,
POST,/api/videos/play,exact_match,VideoController_play,申请视频播放地址,,申请视频播放地址
POST,/api/videos/progress,exact_match,VideoController_progress,上报视频播放进度,,上报视频播放进度
GET,/api/videos/search,exact_match,VideoController_search,搜索通用解析视频,,搜索通用解析视频
GET,/health,legacy_only,HealthController_check,检查 API 与数据库健康状态,,
1 method path status legacy_operation_id legacy_summary target_operation_id target_summary
2 GET /api/ai/school-recommendations legacy_only AiController_list 查询院校推荐报告 AI 延后到 Semantic Kernel + 租户自带 API Key 独立阶段
3 GET /api/ai/school-recommendations/detail legacy_only AiController_detail 获取院校推荐报告详情 AI 延后到 Semantic Kernel + 租户自带 API Key 独立阶段
4 GET /api/ai/school-recommendations/export legacy_only AiController_exportReport 导出院校推荐报告 AI 延后到 Semantic Kernel + 租户自带 API Key 独立阶段
5 POST /api/ai/school-recommendations/generate legacy_only AiController_generate 生成院校推荐报告 AI 延后到 Semantic Kernel + 租户自带 API Key 独立阶段
6 GET /api/assets/{assetId}/download target_only 获取资源下载地址
7 GET /api/assets/{assetId}/preview target_only 获取资源预览地址
8 POST /api/auth/login/password target_only 手机号密码登录
181 GET /api/points/summary target_only 查询当前用户积分摘要
182 GET /api/points/tasks target_only 查询当前可领取积分任务
183 POST /api/points/tasks/claim target_only 领取积分任务奖励
184 GET /api/profile/activity-tasks legacy_only ProfileController_tasks 查询积分活动任务 由 /api/points/tasks 替代
185 POST /api/profile/activity-tasks/claim legacy_only ProfileController_claimTask 领取活动任务奖励 由 /api/points/tasks/claim 替代
186 GET /api/profile/badges exact_match ProfileController_badges 查询徽章列表 查询徽章列表
187 POST /api/profile/check-in legacy_only exact_match ProfileController_checkIn 每日签到 每日签到
188 GET /api/profile/exam-countdowns exact_match ProfileController_countdowns 查询考试倒计时 查询考试倒计时
189 GET /api/profile/exchange-items legacy_only ProfileController_exchangeItems 查询积分兑换商品 由 /api/points/exchange-items 替代
190 POST /api/profile/exchange-items/redeem legacy_only ProfileController_redeem 兑换积分商品 由 /api/points/exchange-orders 替代
191 GET /api/profile/feedbacks exact_match ProfileController_feedbacks 查询反馈记录 查询反馈记录
192 POST /api/profile/feedbacks exact_match ProfileController_submitFeedback 提交意见反馈 提交意见反馈
193 GET /api/profile/me exact_match ProfileController_me 获取当前学生资料 获取当前学生资料
194 PATCH /api/profile/me exact_match ProfileController_updateMe 更新当前学生资料 更新当前学生资料
195 GET /api/profile/notifications exact_match ProfileController_notificationList 查询用户通知 查询用户通知
196 POST /api/profile/notifications/status exact_match ProfileController_notificationStatus 更新通知状态 更新通知状态
197 GET /api/profile/score-events legacy_only exact_match ProfileController_scoreEvents 查询积分流水 查询当前用户积分流水
198 GET /api/questions/videos legacy_only exact_match QuestionVideoController_list 查询单道题目的解析视频 查询单道题目的解析视频
199 POST /api/questions/videos/batch legacy_only exact_match QuestionVideoController_batch 批量查询题目解析视频 批量查询题目解析视频
200 POST /api/referral/bind exact_match ReferralPublicController_bind 绑定当前用户的推荐归属 绑定当前用户推荐归属
201 GET /api/referral/conversion-report exact_match ReferralManagementController_conversion 查询推荐转化与佣金报告 查询推荐转化报告
202 POST /api/referral/invite-code exact_match ReferralPublicController_invite 生成或查询当前成员邀请码 生成或查询当前成员邀请码
346 GET /api/tenant-content/import-jobs target_only 查询内容导入任务
347 GET /api/tenant-content/import-jobs/{jobId} target_only 查询内容导入任务详情
348 GET /api/tenant-content/imports legacy_only TenantContentImportsController_jobs 查询内容导入任务
349 GET /api/tenant-content/imports/detail legacy_only exact_match TenantContentImportsController_detail 查询内容导入任务详情 查询内容导入任务详情
350 GET /api/tenant-content/imports/field-mapping exact_match TenantContentImportsController_fieldMapping 查询导入字段映射说明 查询导入字段映射
351 POST /api/tenant-content/imports/handbook legacy_only TenantContentImportsController_importHandbook 执行或排队知识手册导入 由 /api/tenant-content/imports/{importType} 替代
352 GET /api/tenant-content/imports/issues exact_match TenantContentImportsController_issues 查询内容导入问题明细 查询内容导入问题明细
353 GET /api/tenant-content/imports/post-check exact_match TenantContentImportsController_postCheckStatus 查询内容导入后检查状态 查询内容导入后检查状态
354 POST /api/tenant-content/imports/post-check exact_match TenantContentImportsController_runPostCheck 执行内容导入后完整性检查 执行内容导入后完整性检查
355 POST /api/tenant-content/imports/preview/handbook legacy_only TenantContentImportsController_previewHandbook 预览知识手册导入数据 由 /api/tenant-content/imports/preview/{importType} 替代
356 POST /api/tenant-content/imports/preview/questions legacy_only TenantContentImportsController_previewQuestions 预览题目导入数据 由 /api/tenant-content/imports/preview/{importType} 替代
357 POST /api/tenant-content/imports/preview/scoreline legacy_only TenantContentImportsController_previewScoreline 预览分数线导入数据 由 /api/tenant-content/imports/preview/{importType} 替代
358 POST /api/tenant-content/imports/preview/videos legacy_only TenantContentImportsController_previewVideos 预览视频解析导入数据 由 /api/tenant-content/imports/preview/{importType} 替代
359 POST /api/tenant-content/imports/preview/vocabulary legacy_only TenantContentImportsController_previewVocabulary 预览词汇导入数据 由 /api/tenant-content/imports/preview/{importType} 替代
360 POST /api/tenant-content/imports/preview/{importType} target_only 预览内容导入数据
361 POST /api/tenant-content/imports/questions legacy_only TenantContentImportsController_importQuestions 执行或排队题目导入 由 /api/tenant-content/imports/{importType} 替代
362 POST /api/tenant-content/imports/scoreline legacy_only TenantContentImportsController_importScoreline 执行或排队分数线导入 由 /api/tenant-content/imports/{importType} 替代
363 GET /api/tenant-content/imports/templates exact_match TenantContentImportsController_template 获取内容导入模板 获取内容导入模板
364 POST /api/tenant-content/imports/videos legacy_only TenantContentImportsController_importVideos 执行或排队视频解析导入 由 /api/tenant-content/imports/{importType} 替代
365 POST /api/tenant-content/imports/vocabulary legacy_only TenantContentImportsController_importVocabulary 执行或排队词汇导入 由 /api/tenant-content/imports/{importType} 替代
366 POST /api/tenant-content/imports/{importType} target_only 执行同步内容导入 执行同步或异步内容导入
367 GET /api/tenant-content/media-analytics/asset-events legacy_only TenantContentAssetsController_assetAnalytics 查询媒体资产访问明细
368 GET /api/tenant-content/media-analytics/summary legacy_only TenantContentAssetsController_summary 查询媒体访问分析汇总
369 GET /api/tenant-content/media-analytics/video-events legacy_only TenantContentAssetsController_videoAnalytics 查询媒体视频播放明细
409 GET /api/tenant/current-public target_only 获取公开租户配置
410 GET /api/tenant/resolve exact_match TenantController_resolve 解析当前租户 解析当前租户
411 GET /api/tenants/current target_only
412 POST /api/videos/play legacy_only exact_match VideoController_play 申请视频播放地址 申请视频播放地址
413 POST /api/videos/progress legacy_only exact_match VideoController_progress 上报视频播放进度 上报视频播放进度
414 GET /api/videos/search legacy_only exact_match VideoController_search 搜索通用解析视频 搜索通用解析视频
415 GET /health legacy_only HealthController_check 检查 API 与数据库健康状态

View File

@@ -0,0 +1,75 @@
# 第七阶段:学生端体验与内容消费闭环
第七阶段只补齐学生端和内容消费闭环,不实现 AI 推荐报告。AI 后续单独进入 Semantic Kernel 阶段,租户自己的模型 API Key 通过 `TenantExternalProvider` + `TenantSecret` 配置,业务 DTO、Controller 和普通 Service 不直接接触密钥或 `Microsoft.SemanticKernel` namespace。
## 已实现范围
### 视频消费
新增学生端视频接口:
- `GET /api/videos/search`
- `POST /api/videos/play`
- `POST /api/videos/progress`
- `GET /api/questions/videos`
- `POST /api/questions/videos/batch`
设计边界:
- 播放接口只返回当前租户可访问的视频播放信息。
- API 不暴露 OSS bucket、真实 object key 或 provider 细节。
- 公共题关联视频和租户私题关联视频都必须先通过当前租户可见性校验。
- 播放行为写入 `ContentAssetAccessEvent`
- 播放进度写入 `VideoPlaybackProgress`,同一租户、用户、视频、题目维度幂等更新。
### Profile 与积分
新增学生侧体验接口:
- `POST /api/profile/check-in`
- `GET /api/profile/score-events`
设计边界:
- 签到复用积分任务与积分流水,不另起一套奖励体系。
- 同一用户、同一租户、同一天只能成功签到一次。
- 积分任务领取和积分兑换会同步产生 `UserScoreEvent`,学生端统一从 `score-events` 查询积分变化。
-`/api/profile/activity-tasks``/api/profile/exchange-items` 不恢复为主接口;对应能力由 `/api/points/tasks``/api/points/exchange-items``/api/points/exchange-orders` 替代。
### 内容导入异步化
保留统一导入入口:
- `POST /api/tenant-content/imports/preview/{importType}`
- `POST /api/tenant-content/imports/{importType}`
- `GET /api/tenant-content/imports/detail`
设计边界:
- `questions``vocabulary``handbook``scoreline``videos` 这些旧专用导入语义统一映射为 `importType`
- 小批量可以同步执行。
- 请求显式 `async=true` 或大批量导入时创建 `content_import` 后台任务。
- Worker 通过 `ITenantExecutionScope` 初始化租户 scope 后执行导入,不在请求线程内跑重任务。
- 导入结果写回 import job可通过 detail 接口查询。
## AI 延后约定
本阶段不新增 `/api/ai/**`,不引入 `Microsoft.SemanticKernel` NuGet 包也不实现真实模型调用、prompt、RAG、导出或推荐算法。
后续 AI 阶段默认方向:
- AI Provider 使用 `TenantExternalProvider(capability=ai)`
- 租户 API Key 存入 `TenantSecret`,通过 `SecretRef` 关联。
- Semantic Kernel SDK 只允许出现在 Infrastructure AI provider 实现中。
- Application 层只暴露 `IAiRecommendationProvider``IAiKernelFactory` 等业务抽象。
- Controller 和业务 Service 不直接读取 API Key不直接引用 SK namespace。
## 验收重点
- 租户 A 不能播放租户 B 视频。
- 公共题关联视频和租户私题关联视频都按当前租户权限返回。
- 播放进度重复上报幂等更新。
- 每日签到同一天只能成功一次。
- 签到、积分任务和兑换产生可查询的积分流水。
- 异步导入创建 `content_import` jobWorker 成功写入结果。
- 本阶段不得新增 `Microsoft.SemanticKernel` 引用。