refactor(profile): lock question feedback to v2 delivery

This commit is contained in:
2026-08-06 10:54:18 +08:00
parent b5dd95a97a
commit 42da977655
13 changed files with 26843 additions and 30 deletions

View File

@@ -159,9 +159,9 @@ public sealed class NotificationStatusDto
public sealed class SubmitFeedbackDto
{
/// <summary>
/// 题目 ID。
/// 本次练习中实际交付的会话题目 ID。
/// </summary>
public Guid? QuestionId { get; set; }
public Guid? SessionQuestionId { get; set; }
/// <summary>
/// 类型。
@@ -213,7 +213,7 @@ public sealed class SubmitFeedbackDto
public SubmitFeedbackCommand ToCommand()
{
return new SubmitFeedbackCommand(
QuestionId,
SessionQuestionId,
Type,
Category,
Title,
@@ -223,4 +223,4 @@ public sealed class SubmitFeedbackDto
Attachments,
Metadata);
}
}
}

View File

@@ -80,6 +80,7 @@ internal sealed class ContentExceptionProblemDetailsMapper : ExceptionProblemDet
"profile_access_denied" => StatusCodes.Status403Forbidden,
"profile_user_not_found" or "check_in_task_not_found" or "region_not_found" or "school_not_found" or
"major_not_found" => StatusCodes.Status404NotFound,
_ when code.EndsWith("_not_found", StringComparison.Ordinal) => StatusCodes.Status404NotFound,
_ => StatusCodes.Status400BadRequest
};

View File

@@ -126,7 +126,11 @@ public sealed record FeedbackQuery(string? Status = null, string? Type = null, i
public sealed record FeedbackItem(
Guid Id,
Guid? QuestionId,
Guid? SessionQuestionId,
Guid? QuestionAssetOwnerTenantId,
Guid? QuestionAssetId,
Guid? QuestionRevisionId,
Guid? QuestionPlacementId,
string? Type,
string? Title,
string? Category,
@@ -140,7 +144,7 @@ public sealed record FeedbackItem(
DateTimeOffset UpdatedAt);
public sealed record SubmitFeedbackCommand(
Guid? QuestionId,
Guid? SessionQuestionId,
string? Type,
string? Category,
string? Title,
@@ -199,4 +203,4 @@ public interface IProfileService
Task<ProfileScoreEventList> GetScoreEventsAsync(ProfileActor actor, ProfileScoreEventQuery query,
CancellationToken cancellationToken = default);
}
}

View File

@@ -610,7 +610,11 @@ public sealed record TenantAdminFeedbackItem(
Guid? UserId,
string? UserName,
string? UserPhone,
Guid? QuestionId,
Guid? SessionQuestionId,
Guid? QuestionAssetOwnerTenantId,
Guid? QuestionAssetId,
Guid? QuestionRevisionId,
Guid? QuestionPlacementId,
ReportType? Type,
string? Title,
string? Category,
@@ -629,4 +633,4 @@ public sealed record TenantAdminFeedbackItem(
public sealed class TenantAdminDirectException(string message, string code) : Exception(message)
{
public string Code { get; } = code;
}
}

View File

@@ -19,7 +19,11 @@ public sealed class ExamDate : AuditableTenantEntity
public sealed class Report : AuditableTenantEntity
{
public Guid? QuestionId { get; set; }
public Guid? SessionQuestionId { get; set; }
public Guid? QuestionAssetOwnerTenantId { get; set; }
public Guid? QuestionAssetId { get; set; }
public Guid? QuestionRevisionId { get; set; }
public Guid? QuestionPlacementId { get; set; }
public Guid? UserId { get; set; }
public Guid? HandledBy { get; set; }
public string? LegacyId { get; set; }

View File

@@ -4,7 +4,6 @@ using Tiku.Domain.Catalog;
using Tiku.Domain.Content;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
@@ -50,11 +49,48 @@ internal sealed class ReportConfiguration : IEntityTypeConfiguration<Report>
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.Status, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TenantId, entity.QuestionId, entity.CreatedAt })
.HasFilter("question_id is not null");
builder.HasIndex(entity => new { entity.TenantId, entity.SessionQuestionId, entity.CreatedAt })
.HasDatabaseName("ix_reports_session_question")
.HasFilter("session_question_id is not null");
builder.HasIndex(entity => new
{
entity.QuestionAssetOwnerTenantId,
entity.QuestionAssetId,
entity.QuestionRevisionId
}).HasDatabaseName("ix_reports_question_revision");
builder.HasIndex(entity => new
{
entity.QuestionAssetOwnerTenantId,
entity.QuestionPlacementId
}).HasDatabaseName("ix_reports_question_placement");
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
builder.HasOne<PracticeSessionQuestion>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, Id = entity.SessionQuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionAsset>().WithMany()
.HasForeignKey(entity => new
{
TenantId = entity.QuestionAssetOwnerTenantId,
Id = entity.QuestionAssetId
})
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionRevision>().WithMany()
.HasForeignKey(entity => new
{
TenantId = entity.QuestionAssetOwnerTenantId,
QuestionAssetId = entity.QuestionAssetId,
Id = entity.QuestionRevisionId
})
.HasPrincipalKey(entity => new { entity.TenantId, entity.QuestionAssetId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionPlacement>().WithMany()
.HasForeignKey(entity => new
{
TenantId = entity.QuestionAssetOwnerTenantId,
Id = entity.QuestionPlacementId
})
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<User>().WithMany()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,170 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class MigrateFeedbackEvidenceV2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_reports_questions_tenant_id_question_id",
table: "reports");
migrationBuilder.DropIndex(
name: "ix_reports_tenant_id_question_id_created_at",
table: "reports");
migrationBuilder.RenameColumn(
name: "question_id",
table: "reports",
newName: "session_question_id");
// V1 question identifiers do not identify a delivered V2 session question.
migrationBuilder.Sql("UPDATE reports SET session_question_id = NULL;");
migrationBuilder.AddColumn<Guid>(
name: "question_asset_id",
table: "reports",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "question_asset_owner_tenant_id",
table: "reports",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "question_placement_id",
table: "reports",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "question_revision_id",
table: "reports",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_reports_question_placement",
table: "reports",
columns: new[] { "question_asset_owner_tenant_id", "question_placement_id" });
migrationBuilder.CreateIndex(
name: "ix_reports_question_revision",
table: "reports",
columns: new[] { "question_asset_owner_tenant_id", "question_asset_id", "question_revision_id" });
migrationBuilder.CreateIndex(
name: "ix_reports_session_question",
table: "reports",
columns: new[] { "tenant_id", "session_question_id", "created_at" },
filter: "session_question_id is not null");
migrationBuilder.AddForeignKey(
name: "fk_reports_practice_session_questions_tenant_id_session_questi~",
table: "reports",
columns: new[] { "tenant_id", "session_question_id" },
principalTable: "practice_session_questions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "fk_reports_question_assets_question_asset_owner_tenant_id_ques~",
table: "reports",
columns: new[] { "question_asset_owner_tenant_id", "question_asset_id" },
principalTable: "question_assets",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "fk_reports_question_placements_question_asset_owner_tenant_id_~",
table: "reports",
columns: new[] { "question_asset_owner_tenant_id", "question_placement_id" },
principalTable: "question_placements",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "fk_reports_question_revisions_question_asset_owner_tenant_id_q~",
table: "reports",
columns: new[] { "question_asset_owner_tenant_id", "question_asset_id", "question_revision_id" },
principalTable: "question_revisions",
principalColumns: new[] { "tenant_id", "question_asset_id", "id" },
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_reports_practice_session_questions_tenant_id_session_questi~",
table: "reports");
migrationBuilder.DropForeignKey(
name: "fk_reports_question_assets_question_asset_owner_tenant_id_ques~",
table: "reports");
migrationBuilder.DropForeignKey(
name: "fk_reports_question_placements_question_asset_owner_tenant_id_~",
table: "reports");
migrationBuilder.DropForeignKey(
name: "fk_reports_question_revisions_question_asset_owner_tenant_id_q~",
table: "reports");
migrationBuilder.DropIndex(
name: "ix_reports_question_placement",
table: "reports");
migrationBuilder.DropIndex(
name: "ix_reports_question_revision",
table: "reports");
migrationBuilder.DropIndex(
name: "ix_reports_session_question",
table: "reports");
migrationBuilder.DropColumn(
name: "question_asset_id",
table: "reports");
migrationBuilder.DropColumn(
name: "question_asset_owner_tenant_id",
table: "reports");
migrationBuilder.DropColumn(
name: "question_placement_id",
table: "reports");
migrationBuilder.DropColumn(
name: "question_revision_id",
table: "reports");
migrationBuilder.RenameColumn(
name: "session_question_id",
table: "reports",
newName: "question_id");
migrationBuilder.CreateIndex(
name: "ix_reports_tenant_id_question_id_created_at",
table: "reports",
columns: new[] { "tenant_id", "question_id", "created_at" },
filter: "question_id is not null");
migrationBuilder.AddForeignKey(
name: "fk_reports_questions_tenant_id_question_id",
table: "reports",
columns: new[] { "tenant_id", "question_id" },
principalTable: "questions",
principalColumns: new[] { "tenant_id", "id" },
onDelete: ReferentialAction.Restrict);
}
}
}

View File

@@ -13243,14 +13243,30 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(32)")
.HasColumnName("priority");
b.Property<Guid?>("QuestionId")
b.Property<Guid?>("QuestionAssetId")
.HasColumnType("uuid")
.HasColumnName("question_id");
.HasColumnName("question_asset_id");
b.Property<Guid?>("QuestionAssetOwnerTenantId")
.HasColumnType("uuid")
.HasColumnName("question_asset_owner_tenant_id");
b.Property<Guid?>("QuestionPlacementId")
.HasColumnType("uuid")
.HasColumnName("question_placement_id");
b.Property<Guid?>("QuestionRevisionId")
.HasColumnType("uuid")
.HasColumnName("question_revision_id");
b.Property<string>("Resolution")
.HasColumnType("text")
.HasColumnName("resolution");
b.Property<Guid?>("SessionQuestionId")
.HasColumnType("uuid")
.HasColumnName("session_question_id");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
@@ -13293,13 +13309,19 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.HasIndex("UserId")
.HasDatabaseName("ix_reports_user_id");
b.HasIndex("QuestionAssetOwnerTenantId", "QuestionPlacementId")
.HasDatabaseName("ix_reports_question_placement");
b.HasIndex("TenantId", "LegacyId")
.IsUnique()
.HasDatabaseName("ix_reports_tenant_id_legacy_id");
b.HasIndex("TenantId", "QuestionId", "CreatedAt")
.HasDatabaseName("ix_reports_tenant_id_question_id_created_at")
.HasFilter("question_id is not null");
b.HasIndex("QuestionAssetOwnerTenantId", "QuestionAssetId", "QuestionRevisionId")
.HasDatabaseName("ix_reports_question_revision");
b.HasIndex("TenantId", "SessionQuestionId", "CreatedAt")
.HasDatabaseName("ix_reports_session_question")
.HasFilter("session_question_id is not null");
b.HasIndex("TenantId", "Status", "CreatedAt")
.HasDatabaseName("ix_reports_tenant_id_status_created_at");
@@ -24797,12 +24819,33 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_reports_users_user_id");
b.HasOne("Tiku.Domain.QuestionBanks.Question", null)
b.HasOne("Tiku.Domain.Content.QuestionAsset", null)
.WithMany()
.HasForeignKey("TenantId", "QuestionId")
.HasForeignKey("QuestionAssetOwnerTenantId", "QuestionAssetId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_reports_questions_tenant_id_question_id");
.HasConstraintName("fk_reports_question_assets_question_asset_owner_tenant_id_ques~");
b.HasOne("Tiku.Domain.Content.QuestionPlacement", null)
.WithMany()
.HasForeignKey("QuestionAssetOwnerTenantId", "QuestionPlacementId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_reports_question_placements_question_asset_owner_tenant_id_~");
b.HasOne("Tiku.Domain.Learning.PracticeSessionQuestion", null)
.WithMany()
.HasForeignKey("TenantId", "SessionQuestionId")
.HasPrincipalKey("TenantId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_reports_practice_session_questions_tenant_id_session_questi~");
b.HasOne("Tiku.Domain.Content.QuestionRevision", null)
.WithMany()
.HasForeignKey("QuestionAssetOwnerTenantId", "QuestionAssetId", "QuestionRevisionId")
.HasPrincipalKey("TenantId", "QuestionAssetId", "Id")
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_reports_question_revisions_question_asset_owner_tenant_id_q~");
});
modelBuilder.Entity("Tiku.Domain.Learning.ReportStatusEvent", b =>

View File

@@ -9,7 +9,6 @@ using Tiku.Domain.Common;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.Operations;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
@@ -253,14 +252,32 @@ public sealed class ProfileService(IIdentityPersistence identityPersistence,
{
await EnsureProfileAsync(actor, cancellationToken);
ArgumentException.ThrowIfNullOrWhiteSpace(command.Description);
await AssertReferenceAsync<Question>(actor.TenantId, command.QuestionId, "question_not_found",
cancellationToken);
PracticeSessionQuestion? sessionQuestion = null;
if (command.SessionQuestionId.HasValue)
{
sessionQuestion = await (
from question in learningPersistence.PracticeSessionQuestions.AsNoTracking()
join session in learningPersistence.PracticeSessions.AsNoTracking()
on new { question.TenantId, Id = question.PracticeSessionId }
equals new { session.TenantId, session.Id }
where question.TenantId == actor.TenantId &&
question.Id == command.SessionQuestionId.Value &&
session.UserId == actor.UserId
select question)
.SingleOrDefaultAsync(cancellationToken);
if (sessionQuestion is null)
throw new ProfileException("Session question was not found.", "session_question_not_found");
}
var feedback = new Report
{
TenantId = actor.TenantId,
UserId = actor.UserId,
QuestionId = command.QuestionId,
SessionQuestionId = sessionQuestion?.Id,
QuestionAssetOwnerTenantId = sessionQuestion?.QuestionOwnerTenantId,
QuestionAssetId = sessionQuestion?.QuestionAssetId,
QuestionRevisionId = sessionQuestion?.QuestionRevisionId,
QuestionPlacementId = sessionQuestion?.QuestionPlacementId,
Type = ParseReportType(command.Type ?? "other"),
Title = string.IsNullOrWhiteSpace(command.Title) ? null : command.Title.Trim(),
Category = string.IsNullOrWhiteSpace(command.Category) ? null : command.Category.Trim(),
@@ -573,7 +590,11 @@ public sealed class ProfileService(IIdentityPersistence identityPersistence,
{
return new FeedbackItem(
report.Id,
report.QuestionId,
report.SessionQuestionId,
report.QuestionAssetOwnerTenantId,
report.QuestionAssetId,
report.QuestionRevisionId,
report.QuestionPlacementId,
report.Type.HasValue ? ToWire(report.Type.Value) : null,
report.Title,
report.Category,

View File

@@ -291,7 +291,11 @@ internal abstract partial class TenantAdminServiceBase
report.UserId,
user?.Name ?? user?.UserName,
user?.Phone,
report.QuestionId,
report.SessionQuestionId,
report.QuestionAssetOwnerTenantId,
report.QuestionAssetId,
report.QuestionRevisionId,
report.QuestionPlacementId,
report.Type,
report.Title,
report.Category,

View File

@@ -184,6 +184,14 @@ public sealed class ProfileEndpointTests
Priority = "normal"
});
var feedbackJson = await ReadJsonAsync(feedbackResponse);
var forgedQuestionFeedback = await client.PostAsJsonAsync(
"/api/student/profile/feedbacks",
new SubmitFeedbackDto
{
SessionQuestionId = Guid.NewGuid(),
Type = "suggestion",
Description = "伪造会话题目引用"
});
var feedbacksResponse = await client.GetAsync("/api/student/profile/feedbacks?type=suggestion");
var feedbacksJson = await ReadJsonAsync(feedbacksResponse);
@@ -196,6 +204,7 @@ public sealed class ProfileEndpointTests
Assert.True(badgesJson.RootElement.GetProperty("items").EnumerateArray().Single().GetProperty("isUnlocked")
.GetBoolean());
Assert.Equal(HttpStatusCode.OK, feedbackResponse.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, forgedQuestionFeedback.StatusCode);
Assert.Equal("suggestion", feedbackJson.RootElement.GetProperty("type").GetString());
Assert.Equal(HttpStatusCode.OK, feedbacksResponse.StatusCode);
Assert.Single(feedbacksJson.RootElement.EnumerateArray());
@@ -303,4 +312,4 @@ public sealed class ProfileEndpointTests
var stream = await response.Content.ReadAsStreamAsync();
return await JsonDocument.ParseAsync(stream);
}
}
}

View File

@@ -4,6 +4,8 @@
- 首次决策2026-08-05
- 最近核对2026-08-06
原版查询方式与 V2 的差异见 [V2 题库与原版 tiku-web 查询架构对比](content-domain-v2-vs-legacy-tiku-web.md)。
## 先说结论
V2 题库不是取消树形结构,而是把**编辑结构**和**查询结构**分开:
@@ -467,6 +469,10 @@ PracticeSessionQuestion
└─ Position 本次会话中的顺序
```
题目纠错/举报也必须提交 `SessionQuestionId`,服务端只接受当前学生真实交付过的会话题目,并把
Asset、Revision 与 Placement 证据复制到反馈记录。通用产品建议可以不关联题目;客户端不能直接
提交任意题目资产 ID 来探测或举报未获授权的内容。
```mermaid
flowchart LR
OLD["学生开始练习\n锁定 Revision 1 和评分规则 1"]