feat: add backend requirements for tenant student theme system V2
Some checks failed
ci / release-gate (push) Has been cancelled
Some checks failed
ci / release-gate (push) Has been cancelled
- 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.
This commit is contained in:
@@ -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 后必须无待生成迁移。开发数据不迁移,种子按上述配置重新生成。
|
||||
|
||||
727
docs/tenant-student-theme-system-requirements.md
Normal file
727
docs/tenant-student-theme-system-requirements.md
Normal file
@@ -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 接口。
|
||||
Reference in New Issue
Block a user