From 6e3b746e26a21048367f5df2c112abcdfbd7e462 Mon Sep 17 00:00:00 2001 From: xiong Date: Thu, 6 Aug 2026 10:09:25 +0800 Subject: [PATCH] feat: add backend requirements for tenant student theme system V2 - Introduced comprehensive documentation outlining the backend requirements for the tenant student theme system version 2. - Defined the background, product goals, core design principles, and detailed specifications for the Theme Schema v2. - Established a unique source of truth for theme configurations and outlined API endpoints for theme management. - Included validation rules, error handling, and security measures to ensure robust theme customization capabilities for tenants. --- docs/architecture/content-domain-v2.md | 327 +++++++- ...enant-student-theme-system-requirements.md | 727 ++++++++++++++++++ 2 files changed, 1052 insertions(+), 2 deletions(-) create mode 100644 docs/tenant-student-theme-system-requirements.md diff --git a/docs/architecture/content-domain-v2.md b/docs/architecture/content-domain-v2.md index 990e624..3d15e4a 100644 --- a/docs/architecture/content-domain-v2.md +++ b/docs/architecture/content-domain-v2.md @@ -1,7 +1,21 @@ # 内容领域 V2 与学习访问投影 -状态:Accepted(破坏式重做,开发期) -日期:2026-08-05 +状态:Accepted(V2 交付主链路已落地,管理面继续补齐) +首次决策:2026-08-05 +最近核对:2026-08-06 + +## 文档用途与阅读约定 + +本文是 V2 题库的开发架构说明,覆盖“录题 → 投放 → 发布 → 授权 → 组题 → 答题”的主链路。 +代码中的英文实体名保持不翻译,便于全文搜索;首次出现时附中文职责。本文不替代 OpenAPI、Migration +或代码,三者不一致时以当前代码和数据库模型为准,并回补本文。 + +文中状态含义: + +- **事实模型**:可编辑的题目、大纲、目标、评分和授权配置。 +- **发布投影**:由事实模型编译出的不可变 Release、Audience Segment 和候选题行。 +- **在线快照**:学生当前有效访问范围与已开始练习的交付证据。 +- **目标设计**:已接受但尚未全部自动化或完成基线重建的能力,会明确标注,不视为当前已完成。 ## 决策 @@ -9,6 +23,61 @@ 所有业务使用同一套可配置目标维度。新增考试业务时创建维度、目标 Profile、业务策略、大纲和产品 Manifest,不增加业务枚举或在刷题代码中加入分支。 +## 总体架构 + +```mermaid +flowchart LR + subgraph Authoring["内容生产面(低频写)"] + A1["题目资产 QuestionAsset\n规范题目的稳定身份"] + A2["不可变修订 QuestionRevision\n题干、选项、答案和解析"] + A3["大纲 CurriculumVersion\n章节与知识点结构"] + A4["题目投放 QuestionPlacement\n大纲位置、适用规则、评分政策"] + A1 --> A2 + A1 --> A4 + A3 --> A4 + end + + subgraph Publishing["发布编译面(离线展开)"] + P1["Release 编译器\n校验发布事实并计算适用人群"] + P2["内容版本 ContentRelease\n一次不可变发布"] + P3["人群分段 AudienceSegment\n共享相同 Profile 集合"] + P4["候选题 ContentReleaseQuestion\n锁定 Revision、Placement、评分和顺序"] + P1 --> P2 + P1 --> P3 + P1 --> P4 + end + + subgraph Access["学习授权面(事实合并与投影)"] + G1["租户业务许可 TenantBusinessLicense"] + G2["商品清单 ProductAccessManifestVersion"] + G3["学生权益 / 班级授权"] + G4["有效访问快照 EffectiveAccessProjection\nRelease、Segment、Manifest 及版本号"] + G1 --> G4 + G2 --> G4 + G3 --> G4 + end + + subgraph Delivery["在线交付面(高频读写)"] + D1["认证学习目录\n只展示 Manifest 允许的资源"] + D2["候选读取 ContentCandidateReader\n稳定种子 + 顺序回绕"] + D3["练习会话 PracticeSession"] + D4["会话题快照 PracticeSessionQuestion\n锁定题目与评分证据"] + D5["答题、交卷、错题/报告投影"] + D1 --> D2 --> D3 --> D4 --> D5 + end + + A2 --> P1 + A4 --> P1 + P2 --> G2 + P3 --> G4 + P4 --> D2 + G4 --> D1 + G4 --> D2 +``` + +架构的核心是把“内容是什么”“适用于谁”“谁买到了”“本次实际交付了什么”拆成四套证据, +不让地区、院校、套餐或班级字段反向污染规范题目身份。 + ## 所有权和版本边界 - `QuestionAsset.TenantId` 是资产所有者。平台公共内容使用平台内容所有者;租户私题只能由相同租户搜索、放置和发布。 @@ -18,6 +87,36 @@ - Release 内容行不可修改。撤回只改变 Release 生命周期;升级必须创建新 Release。 - 已开始会话锁定 Revision、Placement、Assessment Policy Version 和分值证据。 +### 核心概念职责 + +| 中文概念 | 代码实体 | 稳定性与职责 | 不应承担的职责 | +| --- | --- | --- | --- | +| 规范题目 | `QuestionAsset` | 一道可持续修订、可跨场景复用的稳定身份;由 `TenantId` 标识所有者 | 不保存地区、章节、套餐或学生权限 | +| 题目修订 | `QuestionRevision` | 不可变交付内容;新改动创建递增 `RevisionNo` | 不原地覆盖历史会话使用的版本 | +| 相似题族 | `QuestionFamily` | 显式关联变体或同源题,辅助人工治理 | 不代表重复题自动合并结果 | +| 大纲 | `Curriculum` / `CurriculumVersion` | 某业务线的一版教学结构 | 不作为题目所有者 | +| 大纲节点 | `CurriculumNode` / `CurriculumNodeClosure` | 表达学科、章节、知识点等树结构及祖先后代查询 | 不直接表达学生权益 | +| 知识概念 | `KnowledgeConcept` | 跨大纲复用的知识语义 | 不等同于某版大纲节点 | +| 题目投放 | `QuestionPlacement` | 将题目放到某版大纲节点,并绑定评分政策、有效期和适用规则 | 不复制题干或答案 | +| 评分政策 | `AssessmentPolicyVersion` | 锁定评分模式、默认分值、舍入及客观题规则 | 不决定题目适用地区 | +| 目标 Profile | `ExamTargetProfileVersion` | 一组维度值形成的可发布考试目标,例如年份、地区、院校、专业 | 不拥有题目 | +| 内容发布 | `ContentRelease` | 某版大纲的一次不可变发布批次 | 发布后不接受原地更新 | +| 人群分段 | `AudienceSegment` | 将适用 Profile 集合相同的投放合并,减少重复候选投影 | 不代表用户或租户群组 | +| 发布候选题 | `ContentReleaseQuestion` | 锁定 Release 中实际可交付的 Revision、Placement、评分政策和稳定顺序 | 不保存学生作答状态 | +| 商品访问清单 | `ProductAccessManifestVersion` | 声明商品允许的 Release、目标、目录资源和题量限制 | 不直接判断当前学生是否有效 | +| 有效访问投影 | `EffectiveAccessProjection` | 合并租户许可、目标选择、商品权益和班级授权后的学生级结果 | 不替代 PostgreSQL 中的授权事实 | +| 会话题快照 | `PracticeSessionQuestion` | 锁定一次练习实际交付的题目与评分证据 | 不随新 Revision 或新 Release 漂移 | + +### “题库、题集、大纲、知识点”如何区分 + +- **题库**是运营和录题视角的工作空间或筛选入口,不参与 V2 规范题目的唯一身份。当前 V2 核心链路直接围绕 + `QuestionAsset` 工作,不能再用 `QuestionBankId` 判断题目是否相同或是否可共享。 +- **题集**是策划好的题目集合。发布态用 `CollectionRelease` 和 `CollectionReleaseQuestion` 锁定所含候选题及顺序; + 它引用发布候选题,不复制规范题目。 +- **大纲**是业务线下的教学结构。`QuestionPlacement` 将同一题目放入某个 `CurriculumNode`;同一题目可以在 + 不同大纲或节点有多个投放。 +- **知识点**分为跨大纲语义 `KnowledgeConcept` 和具体大纲位置 `CurriculumNode`。二者允许关联,但不能互相替代。 + ## 关系图 ```mermaid @@ -42,6 +141,33 @@ erDiagram EFFECTIVE_ACCESS_PROJECTION }o--o{ AUDIENCE_SEGMENT : ids ``` +关系图按中文可分为五组: + +| 分组 | 主要实体 | 说明 | +| --- | --- | --- | +| 题目事实 | `QuestionAsset`、`QuestionRevision`、`QuestionPlacement` | 稳定身份、不可变内容、场景化投放 | +| 教学与目标 | `Curriculum*`、`TargetDimensionDefinition`、`ExamTargetProfile*` | 教学结构和可配置适用目标 | +| 发布投影 | `ContentRelease`、`AudienceSegment*`、`ContentReleaseQuestion` | 将复杂规则编译为在线可直接过滤的数据 | +| 商品授权 | `ProductAccessManifest*`、`StudentEntitlement`、`ClassAssignmentGrant` | 定义可卖内容与学生实际获得的 Grant | +| 在线交付 | `EffectiveAccessProjection`、`PracticeSession*` | 学生级授权快照和会话级不可变交付证据 | + +### 租户所有权规则 + +| 数据 | 所有权范围 | 跨租户访问规则 | +| --- | --- | --- | +| `TargetDimensionDefinition`、`TargetNode`、`ExamTargetProfile*`、`BusinessTargetPolicy*` | 平台全局定义 | 普通租户只按已许可 Profile 使用,不创建同名业务枚举 | +| `QuestionAsset`、`QuestionRevision` | 内容所有者租户 | 投放仅可引用当前租户私题或唯一平台内容租户的公共题 | +| `Curriculum*`、`QuestionPlacement`、`AssessmentPolicy*`、`ContentRelease*` | 发布内容的租户 | 必须以明确 `ContentOwnerTenantId` 进入受审计的 `ITenantExecutionScope` 读取 | +| `ProductAccessManifest*`、`StudentEntitlement`、`ClassAssignmentGrant`、`EffectiveAccessProjection` | 消费租户 | 所有查询必须带当前 `TenantId`,不得从请求体接受任意租户切换 | +| `PracticeSession*`、答案、错题、收藏和报告 | 学生所在租户 | 会话中另存题目所有者 ID,用于读取已授权且锁定的 Revision | + +平台公共题不是“无租户数据”,而是归属于唯一 `TenantMode.PlatformOwned` 内容租户。跨租户读取必须同时满足: + +1. 当前操作已经从 Manifest、Release 或 Placement 得到明确的所有者 ID; +2. 使用受审计 System Scope 切换到该所有者; +3. 查询再次带所有者 ID 和不可变资源 ID; +4. 不把 System Scope 暴露为通用控制器参数。 + ## 目标配置样例 以下是种子/管理接口应表达的配置意图,不是在线授权脚本。 @@ -98,6 +224,201 @@ erDiagram 答题期间不再查询套餐、班级、老师、目标层级或内容规则。强撤权版本仍在答题和交卷入口检查。 +## 规则表达与发布编译 + +### 受限 DNF 适用规则 + +`QuestionPlacementRuleGroup` 之间是 **OR(任一组成立)**,同一组内的 +`QuestionPlacementRuleCondition` 是 **AND(全部成立)**。没有规则组表示该投放适用于当前业务线的全部已发布 +Profile;空规则组不匹配任何 Profile。支持的操作符为: + +| 操作符 | 中文含义 | 判定方式 | +| --- | --- | --- | +| `Exact` | 精确等于 | Profile 在该维度恰好包含目标节点 | +| `DescendantOf` | 等于或属于其后代 | Profile 节点等于目标节点,或祖先集合包含目标节点 | +| `NotExact` | 不精确等于 | 对 `Exact` 结果取反 | +| `NotDescendantOf` | 不属于该节点及后代 | 对 `DescendantOf` 结果取反 | + +示例:“(河南 且 理科)或(山东 且 不属于艺术类)”应建成两个规则组,而不是把四个条件放在同一组。 +发布前必须验证维度和节点匹配、每个投放至少命中一个已发布 Profile,避免生成永远不可达的内容。 + +### 发布编译时序 + +```mermaid +sequenceDiagram + autonumber + participant U as "内容管理员" + participant API as "V2 录题 API" + participant C as "ContentReleaseCompiler(发布编译器)" + participant DB as "PostgreSQL(事实与发布投影)" + participant O as "LearningOutbox(学习事件箱)" + + U->>API: 发布某个 CurriculumVersion(大纲版本) + API->>C: PublishContentReleaseCommand + C->>DB: 开启 Repeatable Read 事务 + C->>DB: 获取“内容所有者 + 大纲版本”事务级 advisory lock + C->>DB: 校验已发布大纲、有效 Placement、题目 Revision 和评分政策 + C->>DB: 读取已发布 Profile 与目标层级,执行受限 DNF 匹配 + C->>DB: 创建 ContentRelease(Compiling) + C->>DB: 写 AudienceSegment / Member / ContentReleaseQuestion + C->>DB: 将 Release 改为 Published + C->>O: 同事务写 content_release_published 事件 + C->>DB: 提交事务 + C-->>API: 返回 ReleaseNo、候选数、Segment 数和 SourceFingerprint +``` + +`SourceFingerprint` 用来证明本次发布输入;`ContentReleaseQuestion.Ordinal` 在 +“Segment + 大纲节点”范围内稳定生成。新 Revision 只影响下一次 Release,不能回写旧 Release。 + +## 学习授权编译 + +### 授权事实链 + +```text +租户是否可经营该业务 +TenantBusinessLicense + TenantLicensedTarget + ↓ +商品卖什么、允许哪些目标和目录资源 +ProductAccessManifestVersion + ├─ ProductManifestRelease + ├─ ProductManifestTarget + └─ ProductManifestResource + ↓ +学生通过什么渠道获得 +StudentEntitlement 或 ClassAssignmentGrant + ↓ +学生当前选择哪个主目标 / 备选目标 +StudentTargetSelectionHistory + ↓ +编译后的学生级访问结果 +EffectiveAccessProjection / EffectiveLearningAccessSnapshot +``` + +`EffectiveLearningAccessService` 按 `(TenantId, UserId, BusinessLineId)` 编译并缓存 30 秒: + +1. 租户和 `TenantBusinessLicense` 必须有效; +2. 学生必须有且仅有一个当前主目标,主/备目标都必须被租户许可; +3. 有效 `StudentEntitlement` 或班级 `ClassAssignmentGrant` 必须关联已发布 Manifest; +4. Profile 角色必须满足 `ProductManifestTarget`,数量不得超过 Manifest 上限; +5. Manifest 引用的 Release 必须存在、已发布且属于相同业务线; +6. 当前 Profile 必须在 Release 的 `AudienceSegmentMember` 中命中至少一个 Segment; +7. 将 Release、Segment、Manifest ID 和三个版本号写入 `EffectiveAccessProjection` 并返回快照。 + +缓存关闭 fail-safe。Redis/FusionCache 未命中可回源 PostgreSQL,但授权事实库失败时不能返回过期授权。 +`GrantVersion` 表示权益变化,`ContentVersion` 表示内容/许可变化,`StrongRevocationVersion` 用于立即阻断已开始会话。 + +普通撤回默认只影响新建会话;账号停用、租户停用或明确强撤权时,提高强撤权版本。答题和交卷入口通过 +一秒、无 fail-safe 的强撤权缓存核对版本,不一致即返回 `practice_access_revoked`。 + +## 在线组题与答题时序 + +```mermaid +sequenceDiagram + autonumber + participant S as "学生端" + participant L as "学习服务" + participant A as "有效访问服务" + participant R as "候选读取器" + participant DB as "PostgreSQL" + + S->>L: 选择目录资源并创建练习 + L->>DB: 根据 ProductManifestResource 确认唯一业务线 + L->>A: 读取当前学生 EffectiveLearningAccessSnapshot + A-->>L: Release / Segment / Manifest / 版本号 + L->>DB: 验证商品权益或班级 Grant,并限制到请求资源 + L->>R: 按内容所有者、Release、Segment、节点/题集、稳定种子取候选 + R-->>L: ContentReleaseQuestion 候选行 + L->>DB: 批量加载锁定 Revision 与 AssessmentPolicyVersion + L->>DB: 原子预留总题量和每日题量 + L->>DB: 创建 PracticeSession 与 PracticeSessionQuestion 快照 + L-->>S: 返回不含答案和解析的会话题目 + S->>L: 答题 / 交卷 + L->>DB: 校验会话版本、幂等键与 StrongRevocationVersion + L->>DB: 使用会话内 GradingRulesSnapshot 评分并写答案事实 + L->>DB: 同事务写 LearningOutbox,异步更新错题等投影 +``` + +候选读取不使用 `ORDER BY random()`。系统以 `(UserId, ResourceType, ResourceId)` 计算稳定种子,在按 +Release、Segment、节点、Ordinal、ID 排序的候选集合上选择起点并回绕;当前单次会话上限为 100 题,候选读取 +服务自身把查询上限限制在 1~500。 + +会话创建时必须锁定: + +- `ContentReleaseQuestionId`:证明候选来自哪次发布; +- `QuestionAssetOwnerTenantId`、`QuestionAssetId`、`QuestionRevisionId`:证明题目身份和具体内容; +- `QuestionPlacementId`:证明当时的大纲位置与适用语境; +- `AssessmentPolicyVersionId` 和 `GradingRulesSnapshot`:证明当时如何评分; +- `GrantVersion`、`StrongRevocationVersion` 与访问快照:证明当时为什么有权创建会话。 + +因此发布新 Revision、撤回旧 Placement 或发布新 Release 都不能改变已开始会话的题目和评分结果。 + +## 状态与变更规则 + +| 聚合 | 状态流 | 变更规则 | +| --- | --- | --- | +| `QuestionAsset` | `Draft → Published → Archived` | 内容变化创建新 `QuestionRevision`;切换 `CurrentRevisionId` 只影响以后发布 | +| `QuestionPlacement` | `Draft → Active → Retired` | 激活后可被下一次 Release 编译;退役不删除历史发布证据 | +| `CurriculumVersion` / `AssessmentPolicyVersion` / Profile Version | `Draft → Published → Retired` | Release 只接受 Published 版本 | +| `ContentRelease` | 当前发布器执行 `Compiling → Published`;`Draft`、`Failed`、`Retired` 是生命周期状态 | 当前事务失败会整体回滚;若后续需要保留 `Failed` 记录,必须由显式失败处理流程写入。Published 内容行不可修改,升级创建新 ReleaseNo | +| `ProductAccessManifestVersion` | `Draft → Published → Retired` | 权益必须钉住明确版本,不跟随可编辑 Definition 漂移 | +| `StudentEntitlement` | `Active → Expired / Revoked / Cancelled` | 普通撤回阻止新会话;强撤权另行提高版本 | +| `ClassAssignmentGrant` | `Active → Revoked / Expired` | 只授权 Manifest 中明确列出的资源 | + +数据库配置和服务校验共同维护不变量。不要只在前端隐藏非法状态,也不要通过物理删除回收已经被 Release、会话或 +答题记录引用的版本。 + +## 平台公共内容与 Cell 投影 + +`PlatformContentPackageVersion` 将平台公共 `ContentRelease` 封装为不可变内容包;消费租户的 +`ProductManifestRelease` 可以同时钉住内容包版本。`PlatformContentPackageCellProjection` 记录某 Cell 已就绪的包版本。 + +应用内容包时按“平台内容所有者 + PackageCode + CellId”加 advisory lock,并用 `EventSequence` 保证: + +- 更小序号被忽略为乱序事件; +- 相同序号和相同版本/哈希是幂等重复; +- 相同序号但版本或哈希不同是冲突; +- 只有 Published 且哈希一致的不可变包可变为 `Ready`。 + +创建练习时,若 Manifest 链接了平台内容包,只有当前租户所在 Cell 的投影为 `Ready` 才能使用;这为后续 Cell +架构的数据分发提供接缝,但不代表跨 Cell 发布、传输和恢复流程已全部自动化。 + +## 代码导航 + +| 开发目的 | 主要入口 | 关键实现 | +| --- | --- | --- | +| 租户私题录入、查重、Revision、Placement、导入、发布 | `api/tenant/content-v2/*` | `ContentV2AuthoringController`、`ContentV2AuthoringService` | +| 平台公共题录入与发布 | `api/platform/content-v2/*` | `PlatformContentV2AuthoringController`、`PlatformContentActorResolver` | +| 题目指纹与适用规则 | Application 纯规则 | `QuestionDeliveryFingerprint`、`QuestionApplicabilityEvaluator` | +| Release 编译 | `IContentReleaseCompiler` | `ContentReleaseCompiler` | +| 在线候选读取 | `IContentCandidateReader` | `ContentCandidateReader` | +| 学生目标与认证目录 | `api/student/learning-context`、`learning-targets`、`catalog/resources` | `StudentLearningTargetService`、`StudentLearningCatalogService` | +| 班级 V2 授权 | `api/tenant/classes/{classId}/learning-assignments` | `V2LearningAccessAdministrationService` | +| 有效访问投影 | `IEffectiveLearningAccessService` | `EffectiveLearningAccessService` | +| 创建与读取练习 | `api/student/learning/practice-sessions*` | `PracticeSessionService`、`V2PracticeSessionService` | +| 答题与强撤权 | 学习答题入口 | `AnsweringService`、`LearningStrongRevocationService` | +| 平台包 Cell 就绪投影 | `IPlatformContentPackageProjectionService` | `PlatformContentPackageProjectionService` | + +领域实体集中在 `Tiku.Domain/Content/ContentV2*.cs` 和 +`Tiku.Domain/Learning/LearningAccessV2Entities.cs`;EF 约束集中在 +`Tiku.Infrastructure/Persistence/Configurations/ContentV2*.cs` 与 +`LearningAccessV2Configurations.cs`。关键回归测试位于 `Tiku.UnitTests/ContentV2RulesTests.cs`、 +`Tiku.IntegrationTests/ContentV2/` 和 `Tiku.IntegrationTests/Api/LearningEndpointTests.cs`。 + +## 当前实现边界 + +截至 2026-08-06,代码已经具备 V2 题目查重/复用、不可变 Revision、Placement、Release 编译、 +Audience Segment、稳定候选读取、Manifest 授权投影、V2 会话快照、强撤权检查和平台内容包 Cell 就绪投影。 + +以下仍应视为后续开发工作,不应从领域实体存在推断为完整产品能力: + +- Curriculum、Profile、BusinessTargetPolicy、AssessmentPolicy、Manifest、CollectionRelease 和 BlueprintRelease 的 + 完整管理 API、审核发布体验与批量运维工具仍需逐项核对和补齐; +- Excel/Word 文件解析不属于 `ContentV2AuthoringService`,该服务接收解析后的结构化行并执行逐行决策; +- Release 发布事件已写入 Outbox,但所有下游内容版本失效、跨 Cell 分发和失败恢复链路仍需按 Worker 实现核验; +- 当前有效访问快照保存 ID 数组,适合基础主链路;全国规模下的基数、索引、缓存失效和 PostgreSQL 查询计划仍需真实数据压测; +- 旧 V1 实体或迁移残留不代表 V2 运行时可以继续依赖。新代码不得重新建立 `QuestionBankId`、地区字段或旧 + `ContentSlice` 到 V2 规范题目身份的耦合。 + ## PostgreSQL 选择 - `ltree`:行政区、院校等单父层级目标节点及祖先/后代匹配。 @@ -110,4 +431,6 @@ erDiagram ## 基线重建要求 +本节是破坏式开发阶段的**目标设计与验收门槛**,不是对当前 Migration 历史已经完成重建的声明。 + 删除 V1 内容/地区授权模型和旧迁移后,新的单一 `InitialSchema` 必须重新包含租户隔离函数与触发器、SaaS 防护对象、特殊 GIN/GiST/部分索引和月分区对象。空 PostgreSQL 执行 DbMigrator 后必须无待生成迁移。开发数据不迁移,种子按上述配置重新生成。 diff --git a/docs/tenant-student-theme-system-requirements.md b/docs/tenant-student-theme-system-requirements.md new file mode 100644 index 0000000..b48805e --- /dev/null +++ b/docs/tenant-student-theme-system-requirements.md @@ -0,0 +1,727 @@ +# 租户学生端主题系统 V2 后端需求 + +> 文档状态:待后端评审 +> +> 需求提出方:学生端前端 +> +> 目标版本:Theme Schema v2 +> +> 适用范围:租户学生 Web/H5;后续可供小程序复用语义 Token +> +> 最后更新:2026-08-05 + +## 1. 背景与结论 + +租户需要在不修改代码、不注入 CSS 的前提下,自定义学生端的品牌、色彩、字体、圆角、密度、阴影、页面骨架和明暗模式,并能从平台内置主题开始修改、预览、发布和回滚。 + +当前接口只能存取缺少明确 Schema 的 JSON,无法向前端提供稳定、可校验、可演进的主题契约。仓库内还并存两套主题事实源: + +- `tenant_frontend_configs` 保存 `PublishedTheme/DraftTheme`,`GET /api/public/runtime/bootstrap` 从这里返回学生端主题; +- `tenant_theme_configs` 保存 `ActiveTheme/DraftTheme`,`GET /api/public/tenant/*` 相关查询优先从这里返回主题; +- 主题管理接口发布时会同步 `TenantBranding.Theme`,但不会同步 `TenantFrontendConfig.PublishedTheme`; +- 前端当前只约定少量颜色、一个原始 `fontFamily` 字符串和一个全局 `radius`,无法表达完整主题,也无法判断后端返回内容是否兼容。 + +因此,本需求的第一优先级不是继续增加任意 JSON 字段,而是建立唯一主题事实源和带版本的受控设计系统契约。 + +### 1.1 当前能力与缺口 + +| 当前实现 | 已有能力 | 仍不足以支撑前端主题编辑的原因 | +| --- | --- | --- | +| `GET /api/tenant/theme-templates` | 查询启用模板 | 模板无不可变版本、能力声明、Schema/客户端兼容范围,也缺少完整的平台治理接口 | +| `GET /api/tenant/theme` | 查询 active/draft | 返回裸 `JsonElement`,草稿 revision 与发布 version 不明确 | +| `POST /api/tenant/theme/preview` | 将模板与覆盖项浅合并后存为草稿 | 实际是“保存草稿”,不是可在真实 Host 安全访问的预览会话;未知字段和值几乎不校验 | +| `POST /api/tenant/theme/publish` | 发布草稿或模板 | 无 expected version、幂等键、历史快照、回滚和模板版本固定;并发管理员可能互相覆盖 | +| `PUT /api/tenant/frontend-config/draft` | 保存包含 theme 的整站配置 | 与上述主题接口形成第二写入口;只验证 JSON 根类型与简单脚本字符串 | +| `GET /api/public/runtime/bootstrap` | Host 解析、ETag、公共缓存 | 读取另一套主题数据,ETag 只依赖旧 ConfigVersion,主题接口发布后不能保证学生端立即得到同一结果 | +| 前端 ThemeConfig | 10 个颜色、字体字符串、全局圆角 | 缺少暗色、排版尺度、密度、阴影、动效、布局/组件变体、能力协商和强类型 OpenAPI | + +## 2. 产品目标 + +### 2.1 必须实现 + +1. 租户管理员可选择平台内置预设主题,并在预设基础上覆盖允许修改的设计 Token。 +2. 可配置浅色主题;可选配置深色主题,并设置默认模式及是否允许学生切换。 +3. 可安全配置品牌图片和主题图片,所有资源使用平台资产 ID,不接受任意外链作为正式配置。 +4. 草稿不影响线上学生端;管理员可生成真实学生端预览,确认后再发布。 +5. 发布具备并发冲突保护、历史快照、审计、回滚和全链路缓存失效。 +6. 学生端只通过 Host 解析出的租户上下文获取已发布主题,不传入客户端指定的 `tenantId`。 +7. 后端对主题做结构、值域、安全、资源归属和基础可访问性校验,并返回前端可定位到具体表单项的错误。 +8. 平台能够维护内置主题的版本、上下线状态和兼容范围。 + +### 2.2 本期不做 + +- 不允许租户上传或填写任意 CSS、HTML、JavaScript。 +- 不开放任意组件代码、任意 DOM 属性或任意 URL Scheme。 +- 不把导航、首页模块编排、功能开关混入 Theme Schema;这些仍属于站点结构配置。 +- 不实现自由拖拽页面搭建器。主题系统只控制已登记组件的视觉变量和有限布局变体。 +- 不允许租户主题覆盖平台后台或其他租户的界面。 + +## 3. 核心设计原则 + +### 3.1 唯一事实源 + +`tenant_theme_configs`(或后端评审后确定的新表)必须成为租户主题的唯一写入与发布事实源。 + +`GET /api/public/runtime/bootstrap` 必须从统一的主题读取服务取得已发布快照。`TenantFrontendConfig.Theme` 不再独立编辑;完成迁移后应删除,或在兼容期内明确标记为只读派生字段。禁止两边双写后长期共存。 + +建议的运行时链路: + +```text +Host + -> TenantResolutionMiddleware + -> ITenantContext + -> TenantThemeRuntimeService + -> 已发布不可变快照 + -> /api/public/runtime/bootstrap + -> ETag / OutputCache / Redis / L1 +``` + +### 3.2 预设加覆盖,不复制整份模板 + +租户配置由以下内容组成: + +- `baseTemplateCode` 与不可变的 `baseTemplateVersion`; +- 租户 `overrides`; +- 后端解析出的 `resolvedTheme`。 + +运行时返回发布时生成的完整 `resolvedTheme` 快照,不能在每次请求时依赖“当前最新版模板”动态合并。平台升级模板不得悄悄改变已发布租户站点。 + +### 3.3 语义 Token,不暴露实现细节 + +API 使用 `color.text.primary`、`component.button.radius` 等语义字段,不向租户暴露 `.dashboard-hero`、Ant Design Token 名称或 CSS 变量名。前端负责把稳定语义 Token 映射到当前技术栈。 + +### 3.4 JSONB 可以保留,但契约必须强类型 + +数据库可以继续使用 JSONB 存储 Token;应用层、OpenAPI 和验证层必须使用强类型 DTO。OpenAPI 中主题不能继续只是无约束的 `JsonElement`。 + +## 4. Theme Schema v2 + +### 4.1 顶层结构 + +```json +{ + "schemaVersion": 2, + "baseTemplate": { + "code": "clarity", + "version": 3 + }, + "mode": { + "default": "light", + "allowStudentSwitch": false + }, + "tokens": { + "light": {}, + "dark": null, + "typography": {}, + "shape": {}, + "spacing": {}, + "shadow": {}, + "motion": {}, + "layout": {}, + "component": {} + }, + "assets": {}, + "metadata": { + "displayName": "我的主题" + } +} +``` + +`tokens.light` 必填;只有模板声明支持深色且租户启用时才允许 `tokens.dark`。`mode.default` 可选值为 `light | dark | system`。 + +### 4.2 颜色 Token + +浅色与深色模式使用同一结构: + +```json +{ + "brand": { + "primary": "#3157D5", + "primaryHover": "#2748B8", + "primaryActive": "#203C98", + "secondary": "#20B486" + }, + "background": { + "page": "#F5F7FB", + "surface": "#FFFFFF", + "elevated": "#FFFFFF", + "muted": "#EEF2F8" + }, + "text": { + "primary": "#172033", + "secondary": "#667085", + "muted": "#98A2B3", + "inverse": "#FFFFFF", + "link": "#3157D5" + }, + "border": { + "default": "#DFE5EF", + "strong": "#C5CEDA", + "focus": "#3157D5" + }, + "status": { + "success": "#15803D", + "warning": "#B45309", + "danger": "#DC2626", + "info": "#2563EB" + } +} +``` + +要求: + +- V2 首期只接受规范化的 6/8 位十六进制颜色;不接受 `url()`、`var()`、`calc()`、渐变或任意 CSS 值。 +- Hover/Active 等衍生色可以省略,由后端确定性生成并写入发布快照;生成算法版本必须进入 `resolverVersion`。 +- 前景与背景组合必须通过对比度检查。正文目标为 WCAG AA 4.5:1,大字号及关键非文本控件目标为 3:1。 +- 品牌色无法满足文本对比度时,后端应给出可定位的错误或安全修正建议,不能静默发布不可读主题。 + +### 4.3 字体 Token + +```json +{ + "fontPreset": "professional-sans", + "scale": "comfortable", + "baseSize": 16, + "headingWeight": 700, + "bodyWeight": 400, + "lineHeight": 1.6 +} +``` + +要求: + +- `fontPreset` 只能引用平台字体目录,不接受任意 `fontFamily`、远程字体 URL 或租户上传字体文件。 +- 首期预设至少包含 `system-sans`、`professional-sans`、`academy-serif-heading`。 +- `baseSize` 范围 14–18,`lineHeight` 范围 1.4–1.8,字重来自白名单。 +- 字体目录响应需包含中英文名称、字体栈、支持字重、加载策略和许可证标识;运行时只需返回解析后的安全字体栈及字体预设 ID。 + +### 4.4 形状、间距、阴影与动效 + +```json +{ + "shape": { + "radiusScale": "medium", + "controlRadius": 10, + "cardRadius": 16, + "pillRadius": 999 + }, + "spacing": { + "density": "comfortable", + "contentGap": 20, + "sectionGap": 28 + }, + "shadow": { + "style": "soft", + "card": "sm", + "floating": "md" + }, + "motion": { + "level": "subtle", + "duration": "normal" + } +} +``` + +要求: + +- `density`: `compact | comfortable | spacious`。 +- `shadow.style`: `none | crisp | soft`;具体 box-shadow 由前端映射,接口不接受原始 CSS shadow。 +- `motion.level`: `none | subtle | expressive`。无论租户配置为何,前端都必须尊重 `prefers-reduced-motion`。 +- 所有数值都需要后端上下限;禁止负数、NaN、超大值及可导致布局失控的单位字符串。 + +### 4.5 布局与组件变体 + +```json +{ + "layout": { + "studentShell": "sidebar", + "contentWidth": "wide", + "headerStyle": "solid", + "navigationStyle": "soft", + "heroStyle": "gradient" + }, + "component": { + "buttonStyle": "solid", + "cardStyle": "bordered", + "inputStyle": "outlined", + "metricStyle": "icon-tile" + } +} +``` + +所有值必须来自平台和前端共同维护的枚举白名单。首期建议: + +- `studentShell`: `sidebar | topbar`;移动端仍由前端统一适配为底部 Dock 或抽屉; +- `contentWidth`: `standard | wide | fluid`; +- `headerStyle`: `solid | translucent`; +- `navigationStyle`: `plain | soft | filled`; +- `heroStyle`: `minimal | gradient | image`; +- `buttonStyle`: `solid | soft | outline`; +- `cardStyle`: `flat | bordered | elevated`; +- `inputStyle`: `outlined | filled`。 + +模板必须声明其 `supportedCapabilities`。租户不能覆盖模板或当前前端版本不支持的变体。 + +### 4.6 主题资源 + +```json +{ + "logoAssetId": "uuid", + "faviconAssetId": "uuid", + "loginBackgroundAssetId": "uuid", + "heroAssetId": "uuid", + "serviceQrAssetId": "uuid", + "featureCardAssetIds": { + "learning": "uuid" + }, + "alt": { + "logo": "某某教育", + "hero": "成人学历提升学习场景" + }, + "focalPoint": { + "hero": { "x": 0.72, "y": 0.45 } + } +} +``` + +要求: + +- 正式配置只保存资产 ID;运行时由后端解析为可公开访问的版本化 URL。 +- 发布前校验资产属于当前租户、状态可用、用途匹配、MIME/尺寸/文件大小合规,不得引用私有或其他租户资产。 +- Logo、Hero 等关键非装饰图片必须提供 `alt`;纯装饰图片可显式标记为空字符串。 +- 图片应有已处理的 WebP/AVIF 等衍生版本;运行时可返回 `srcSet`、宽高和占位色,避免布局偏移。 +- Hero 提供 0–1 范围的焦点坐标,供不同屏幕裁切;禁止前端自行猜测重要区域。 + +## 5. 内置预设主题 + +首期至少内置并由 DbMigrator 幂等写入以下预设: + +| Code | 中文名 | 方向 | 默认模式 | +| --- | --- | --- | --- | +| `clarity` | 清晰专业 | 蓝色、克制、适合通用成人教育 | Light | +| `academy` | 学院沉稳 | 墨绿与暖金、标题可用衬线 | Light | +| `energy` | 活力进阶 | 橙色、强调行动与任务反馈 | Light | +| `night-focus` | 夜间专注 | 深色低眩光、适合晚间学习 | Dark/System | + +模板必须版本化且历史版本不可修改。建议拆为: + +- `tenant_theme_templates`:稳定身份、名称、状态、排序; +- `tenant_theme_template_versions`:`templateCode + version` 唯一,保存 Schema、默认 Token、资源、能力、预览图、发布时间; +- 模板状态:`draft | active | deprecated | disabled`; +- 已发布租户可继续使用 `deprecated` 版本,但新建/切换时不可选择;`disabled` 仅用于安全紧急下线,并必须提供替代策略。 + +模板列表需要返回 `isCurrent`、当前版本、模式支持、能力列表、预览图、适用终端、最低 Theme Schema 和最低客户端版本。 + +## 6. 状态模型与数据模型 + +### 6.1 版本概念 + +必须区分: + +- `schemaVersion`:主题数据结构版本; +- `templateVersion`:内置模板不可变版本; +- `draftRevision`:每次成功保存草稿递增,用于编辑并发控制; +- `publishedVersion`:每次发布或回滚递增,是运行时缓存与 ETag 的版本; +- `resolverVersion`:后端模板合并、颜色派生等算法版本。 + +### 6.2 建议表结构 + +`tenant_theme_configs`: + +- `tenant_id`,唯一; +- `draft_revision`; +- `draft_base_template_code/version`; +- `draft_overrides jsonb`; +- `draft_resolved_snapshot jsonb`; +- `draft_updated_by/at`; +- `published_version`; +- `published_snapshot_id`; +- `published_at/by`; +- EF 并发令牌或等价条件更新字段。 + +`tenant_theme_versions`: + +- `id`; +- `tenant_id + published_version` 唯一; +- `schema_version`、`resolver_version`; +- `base_template_code/version`; +- `overrides jsonb`; +- `resolved_theme jsonb`; +- `change_note`; +- `published_by/at`; +- `source_version_id`,回滚时记录来源; +- `content_hash`,用于完整性检查和去重诊断。 + +发布历史为不可变快照,不允许 UPDATE/DELETE 普通历史记录。保留策略由平台运维配置,默认至少保留最近 50 个版本和最近 12 个月记录。 + +## 7. 租户管理 API + +建议统一在 `/api/tenant/themes`,权限使用 `TenantSettingsManage`,并保留 AllDataScope 校验。 + +### 7.1 查询能力与模板 + +```http +GET /api/tenant/themes/schema +GET /api/tenant/themes/templates?status=selectable +GET /api/tenant/themes/templates/{code}/versions/{version} +``` + +`schema` 返回当前 Schema 版本、字段定义、枚举、数值范围、字体目录、资源要求、能力标识和已废弃字段。该响应可缓存,并带 ETag。 + +### 7.2 查询当前主题 + +```http +GET /api/tenant/themes/current +``` + +响应必须同时返回 `published`、可空的 `draft`、各自版本、编辑者、时间、校验摘要,不得用一个 `Status` 枚举把草稿和线上状态互斥表示。 + +### 7.3 保存草稿 + +```http +PUT /api/tenant/themes/draft +If-Match: "draft-12" +``` + +```json +{ + "expectedDraftRevision": 12, + "baseTemplate": { "code": "clarity", "version": 3 }, + "overrides": { + "tokens": { + "light": { + "brand": { "primary": "#234FC7" } + }, + "typography": { "fontPreset": "professional-sans" }, + "layout": { "studentShell": "sidebar" } + }, + "assets": { "heroAssetId": "uuid" } + }, + "metadata": { "displayName": "秋季招生主题" } +} +``` + +成功返回新的 `draftRevision`、完整 `resolvedTheme`、`validation` 和 `ETag`。版本不一致返回 `409 theme_draft_revision_conflict`,并提供当前 revision,不覆盖他人草稿。 + +另需: + +```http +DELETE /api/tenant/themes/draft?expectedDraftRevision=13 +POST /api/tenant/themes/draft/reset +``` + +`reset` 将草稿重置为当前已发布主题或指定模板,具体行为必须由请求参数明确,不能隐式猜测。 + +### 7.4 校验 + +```http +POST /api/tenant/themes/validate +``` + +保存草稿时也必须执行同一套校验。建议响应: + +```json +{ + "valid": false, + "errors": [ + { + "code": "theme_contrast_insufficient", + "path": "/tokens/light/text/primary", + "message": "正文文字与页面背景对比度为 3.8:1,最低要求为 4.5:1", + "meta": { "actual": 3.8, "required": 4.5 } + } + ], + "warnings": [] +} +``` + +错误使用 `422 Unprocessable Entity`;字段路径使用 JSON Pointer,错误码保持稳定、消息可本地化。 + +### 7.5 真实预览 + +```http +POST /api/tenant/themes/preview-sessions +``` + +请求携带 `draftRevision` 和预览设备信息。后端返回一次性或短时预览凭证: + +```json +{ + "previewUrl": "https://tenant.example.com/?themePreview=opaque-token", + "expiresAt": "2026-08-05T12:30:00Z" +} +``` + +要求: + +- Token 随机、不可枚举、与租户/草稿 revision/创建人绑定,默认 15 分钟过期;服务端只存哈希或使用可撤销签名方案。 +- 预览 URL 必须仍经过 Host 租户解析;Token 中的 tenant 与 Host 不一致时返回 403。 +- 只有已登录且具备主题管理权限的用户可消费预览;响应设置 `Cache-Control: private, no-store` 与 `X-Robots-Tag: noindex`。 +- 预览只覆盖主题读取,不改变数据库中的已发布指针,不污染公共 OutputCache、Redis 或 CDN。 +- 创建新 revision 后旧预览可继续显示其绑定快照直至过期,便于对比;也应支持主动撤销。 + +### 7.6 发布 + +```http +POST /api/tenant/themes/publish +Idempotency-Key: uuid +``` + +```json +{ + "expectedDraftRevision": 13, + "expectedPublishedVersion": 7, + "changeNote": "更新秋季品牌色和首页主视觉" +} +``` + +发布必须在一个数据库事务中: + +1. 校验权限、revision、published version、模板状态和全部资产; +2. 重新解析主题,不能无条件信任保存草稿时的快照; +3. 写入不可变历史版本; +4. 原子切换当前 published 指针并递增 `publishedVersion`; +5. 写审计事件; +6. 事务提交后失效主题 L1/Redis、公共 Bootstrap OutputCache/CDN,并发布失效事件。 + +相同 `Idempotency-Key` 和相同请求必须返回同一结果;同 Key 不同请求返回 `409 idempotency_conflict`。 + +### 7.7 历史与回滚 + +```http +GET /api/tenant/themes/versions?cursor=&limit=20 +GET /api/tenant/themes/versions/{publishedVersion} +POST /api/tenant/themes/rollback +``` + +```json +{ + "targetPublishedVersion": 5, + "expectedPublishedVersion": 8, + "changeNote": "回滚主题,修复线上可读性问题" +} +``` + +回滚不是把指针静默改回旧值,而是基于目标快照创建新的 `publishedVersion = 9`,保留完整审计链。若旧资源已不可用,应拒绝回滚并列出问题;不得发布残缺主题。 + +## 8. 平台管理 API + +平台管理员需要具备模板治理能力,建议: + +```http +GET /api/platform/theme-templates +POST /api/platform/theme-templates +POST /api/platform/theme-templates/{code}/versions +POST /api/platform/theme-templates/{code}/versions/{version}/activate +POST /api/platform/theme-templates/{code}/deprecate +POST /api/platform/theme-templates/{code}/disable +``` + +要求: + +- 创建新版本而非覆盖已激活版本; +- 激活前执行完整 Schema、资产、对比度和前端能力兼容检查; +- 返回使用该版本的租户数量,停用时展示影响范围; +- 平台模板权限与租户主题权限分离;所有操作写平台审计日志; +- 提供 `replacementTemplateCode/version`,支持后续批量迁移,但批量迁移不得未经租户确认直接改变已发布视觉,安全紧急事件除外。 + +## 9. 学生端运行时契约 + +继续使用 Host 绑定的: + +```http +GET /api/public/runtime/bootstrap +``` + +建议将响应中的 `theme` 改为强类型: + +```json +{ + "theme": { + "schemaVersion": 2, + "publishedVersion": 9, + "resolverVersion": 1, + "template": { "code": "clarity", "version": 3 }, + "mode": { + "default": "light", + "allowStudentSwitch": false, + "available": ["light"] + }, + "tokens": { + "light": {}, + "dark": null, + "typography": {}, + "shape": {}, + "spacing": {}, + "shadow": {}, + "motion": {}, + "layout": {}, + "component": {} + }, + "assets": { + "logo": { + "url": "/api/public/assets/...", + "width": 240, + "height": 72, + "alt": "某某教育" + } + }, + "capabilities": ["shell.sidebar", "hero.image", "dark-mode"] + } +} +``` + +运行时要求: + +- 只返回已发布、已解析、可直接渲染的安全快照;绝不返回草稿和后台元数据。 +- ETag 至少包含租户、站点配置版本、主题 `publishedVersion` 及会影响结果的能力/订阅版本,不能只使用旧 `ConfigVersion`。 +- 发布/回滚后下一次请求必须看见新版本;已有 304、L1、Redis、OutputCache 和 CDN 全部使用同一版本或可靠失效。 +- 主题不可用或旧 Schema 无法迁移时回退到平台安全默认主题,并产生可观测告警;不得返回半份配置导致白屏。 +- 学生个人的 `light/dark/system` 偏好只存偏好值,不复制租户主题。租户关闭切换时忽略个人偏好。 +- OpenAPI 必须生成明确类型,前端不应再把 `theme` 当作 `JsonElement` 手写断言。 + +## 10. 校验、安全与限制 + +### 10.1 结构与资源限制 + +- 拒绝未知字段,或将兼容策略明确为“保存时拒绝、读取时忽略未来字段”;首期推荐拒绝未知字段。 +- 单份 overrides 建议最大 64 KiB,完整 resolved snapshot 建议最大 256 KiB,数组和 Map 项数分别设上限。 +- 字符串统一去除首尾空白并限制长度;颜色、枚举、UUID 必须严格解析。 +- 禁止 `script`、`html` 只是最低防线;还必须禁止 CSS 表达式、外链字体、Data URL、SVG 脚本、非 HTTPS 外链及未登记字段。 +- 公共响应不包含内部资产路径、存储桶密钥、编辑者信息或审计详情。 + +### 10.2 权限与租户隔离 + +- 所有租户写接口从可信 `ITenantContext` 取 tenant,不接收请求体中的任意 tenant ID。 +- 平台代管租户主题时使用独立的平台接口、显式目标租户和平台权限,不复用租户接口绕过 Realm。 +- PostgreSQL 查询必须带租户隔离;资产引用校验必须同时校验 `tenant_id`。 +- 草稿读取、预览、历史和回滚均属于敏感管理操作,必须写审计。 + +### 10.3 审计事件 + +至少记录: + +- `tenant.theme.draft_saved` +- `tenant.theme.draft_discarded` +- `tenant.theme.preview_created` +- `tenant.theme.published` +- `tenant.theme.rolled_back` +- `tenant.theme.publish_failed` +- `platform.theme_template.version_activated` +- `platform.theme_template.deprecated` + +审计详情包含版本、模板、变更字段路径摘要、操作者、请求关联 ID;不保存预览 Token 和敏感资源 URL。 + +## 11. 错误契约 + +所有错误使用 `ProblemDetails`,`code` 稳定。至少包含: + +| HTTP | Code | 场景 | +| --- | --- | --- | +| 400 | `theme_request_invalid` | 请求基础格式错误 | +| 403 | `theme_access_denied` | 无权限或预览 Host/tenant 冲突 | +| 404 | `theme_template_not_found` | 模板或版本不存在/不可选 | +| 404 | `theme_draft_not_found` | 没有草稿 | +| 409 | `theme_draft_revision_conflict` | 草稿被其他编辑者更新 | +| 409 | `theme_published_version_conflict` | 发布基线已变化 | +| 409 | `idempotency_conflict` | 幂等键被用于不同请求 | +| 410 | `theme_preview_expired` | 预览凭证过期或撤销 | +| 422 | `theme_schema_unsupported` | Schema 不受支持 | +| 422 | `theme_token_invalid` | Token 名称或值非法 | +| 422 | `theme_contrast_insufficient` | 可访问性对比度不达标 | +| 422 | `theme_asset_invalid` | 资源归属、状态、格式或尺寸不合规 | +| 503 | `theme_publish_unavailable` | 关键依赖不可用,发布未发生 | + +## 12. 兼容与迁移方案 + +### P0:统一事实源 + +1. 盘点 `TenantFrontendConfig.Theme`、`TenantThemeConfig`、`TenantBranding.Theme` 的所有读写路径。 +2. 确定 `TenantThemeConfig`/Theme V2 服务为唯一写模型。 +3. 迁移现有 `primaryColor`、`secondaryColor`、`backgroundColor`、`textColor`、`fontFamily`、`radius` 到 V2 Token。 +4. 无法识别的旧字段记录迁移告警,不直接带入公共配置。 +5. Bootstrap 改读统一发布快照,ETag 和全部缓存加入主题版本。 +6. 兼容期内旧接口只读映射 V2;写接口返回明确弃用信息,并在约定版本后移除。 + +### P1:可用闭环 + +实现强类型 Schema、四套内置主题、草稿并发控制、校验、资产引用、真实预览、原子发布、历史与回滚、审计和缓存失效。 + +### P2:增强能力 + +增加深色模式、模板平台治理、模板升级建议、差异对比、定时发布、发布审批和多终端能力声明。P2 不应阻塞 P1 的安全可用闭环。 + +迁移上线建议使用特性开关和双读比对:可以短期对比旧/新解析结果并记录指标,但不允许长期双写作为最终架构。切换后保留快速回退到旧读取路径的发布开关,数据库历史快照不回滚删除。 + +## 13. 非功能要求 + +- 已缓存的公共 Bootstrap:服务端处理 p95 目标小于 50 ms;未缓存读取 p95 目标小于 200 ms,具体以真实 PostgreSQL/Redis 环境压测为准。 +- 发布成功到所有公共缓存可见的一致性目标小于 5 秒;API 成功响应前至少完成数据库提交和可靠失效事件落库。 +- 公共主题响应建议 gzip 后小于 50 KiB,不内联图片和字体二进制。 +- 发布、回滚、校验失败、缓存失效失败、默认主题回退均有指标、结构化日志和关联 ID。 +- 任一租户的超大或错误配置不能拖垮其他租户;解析结果按 tenant + publishedVersion 隔离缓存。 + +## 14. 验收标准 + +### 14.1 功能验收 + +1. 新租户无需配置即可获得 `clarity` 安全默认主题。 +2. 租户选择任一内置模板、修改允许的 Token 和资源后,可在桌面/移动预览中看到一致结果;线上学生端不受草稿影响。 +3. 发布后,通过该租户真实 Host 请求 Bootstrap 能拿到新 `publishedVersion`,旧 ETag 不再返回 304。 +4. 两个管理员同时编辑时,后保存者收到 409,不会覆盖先保存者。 +5. 回滚旧版本会生成新的发布版本,审计链完整,且可再次回滚。 +6. 模板发布新版本后,已发布租户视觉不发生自动变化。 +7. 其他租户资产、过期资产、非法颜色、未知 Token、低对比度正文和任意 CSS 均无法发布。 +8. 深色模式未配置时,运行时不会声称支持 dark;租户禁止切换时学生偏好不改变模式。 + +### 14.2 自动化测试 + +后端至少覆盖: + +- Schema/枚举/范围/未知字段验证; +- 颜色对比度与安全值验证; +- 资产归属、用途、状态和跨租户拒绝; +- 模板版本不可变及 deprecated 行为; +- 草稿 revision、发布 version、幂等与事务回滚; +- 真实 PostgreSQL 下唯一约束和并发发布; +- Host 租户解析、Realm 权限和预览租户冲突; +- 发布/回滚后的 L1、Redis、OutputCache、ETag 失效; +- 旧 Theme v1 数据迁移和安全默认回退; +- OpenAPI 生成结果中 `theme` 为强类型而非裸 `JsonElement`。 + +前后端联合验收至少覆盖 1440、1024、768、375 四种宽度,检查无水平溢出、文字可读、焦点可见、键盘可操作、减少动效偏好以及浅色/深色切换。 + +## 15. 前后端职责边界 + +后端负责: + +- Theme Schema、模板及其版本; +- 强类型验证、安全与租户隔离; +- 草稿、预览授权、发布、历史、回滚、审计; +- 资产归属与公开 URL 解析; +- 运行时快照、版本、缓存和兼容迁移。 + +前端负责: + +- 根据 Schema/能力渲染主题编辑器; +- 将语义 Token 映射到 CSS 变量与组件库; +- 桌面/移动实时编辑预览和后端真实预览入口; +- 对不认识的未来 Schema 安全失败并使用平台默认主题; +- 尊重系统可访问性设置,不执行后端返回的任何代码或原始 CSS。 + +前后端共同维护一份契约测试样例:每个内置模板的 resolved snapshot、Token 到 UI 的视觉回归页面,以及当前客户端支持的 capability 清单。 + +## 16. 后端交付清单 + +- [ ] 主题唯一事实源与旧模型迁移说明 +- [ ] Theme Schema v2 强类型 Application/API 模型 +- [ ] 数据库迁移及 DbMigrator 内置模板幂等种子 +- [ ] 租户主题草稿、校验、预览、发布、历史、回滚 API +- [ ] 平台模板版本治理 API +- [ ] 统一的公共 Bootstrap 主题契约与 ETag/缓存失效 +- [ ] 资产归属和公开资源解析 +- [ ] ProblemDetails 错误码与 OpenAPI 示例 +- [ ] 单元测试、真实 PostgreSQL 集成测试、租户隔离测试 +- [ ] 迁移/回退/监控说明及前端联调文档 + +后端评审时需要先给出三项明确结论:最终唯一事实源选哪一套、Theme v1 数据如何迁移、P1 API 与表结构是否能完整支持“草稿 → 真实预览 → 发布 → 回滚”的闭环。未解决这三项前,不建议继续扩展当前任意 JSON 接口。