From 5732df8886fff70107b8dc8df05dab9e8073d15d Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 28 Jul 2026 16:22:50 +0800 Subject: [PATCH] docs: simplify migration documentation --- README.md | 284 +++------- docs/adr/0001-authoritative-dotnet-backend.md | 26 +- ...entication-authorization-hardening-plan.md | 444 ++-------------- .../authentication-authorization-security.md | 495 +++--------------- docs/migration-roadmap.md | 208 ++------ docs/migration/contracts/README.md | 10 +- docs/migration/phase-1-repository-baseline.md | 80 +-- .../phase-2-engineering-foundation.md | 94 +--- ...nant-isolation-and-shared-question-bank.md | 273 ++-------- .../phase-4-external-provider-decoupling.md | 163 ++---- .../phase-5-backoffice-worker-operations.md | 201 +------ ...dent-experience-and-content-consumption.md | 71 +-- docs/migration/phase-8-ai-foundation.md | 151 ++---- 13 files changed, 484 insertions(+), 2016 deletions(-) diff --git a/README.md b/README.md index 6ca5e4c..141c54d 100644 --- a/README.md +++ b/README.md @@ -1,267 +1,95 @@ # TIKU Backend -题库 SaaS 正式后端。项目已从旧 PocketBase / Supabase / Nest 方案收敛到 ASP.NET Core + PostgreSQL 架构。本仓库是后续开发的唯一目标后端,架构决策见 [`docs/adr/0001-authoritative-dotnet-backend.md`](docs/adr/0001-authoritative-dotnet-backend.md)。 +题库 SaaS 的正式后端。项目已收敛到 ASP.NET Core + EF Core + PostgreSQL,本仓库是后续开发的唯一目标后端。架构决策见 [ADR 0001](docs/adr/0001-authoritative-dotnet-backend.md)。 -当前判断很明确:现在团队已经有后端开发,继续把核心认证、权限、多租户隔离、业务一致性交给 BaaS 规则,会让复杂度藏在平台、SQL policy 和前端约定之间。新后端把这些东西收回应用层和数据库约束里,开发、排查、审计都会更直接。 +## 架构边界 -## 当前架构定位 +- 旧 NestJS / Supabase 仓库只作为业务行为和接口清单参考,不作为运行时依赖。 +- 不兼容旧 Supabase 数据库、RLS、Storage bucket、Refresh Token 或旧题单 JSON。 +- PostgreSQL 按标准 PostgreSQL 使用,不绑定 Supabase 托管能力。 +- 普通 schema 由 EF Core entity、Fluent Configuration 和 Migration 管理;`Tiku.DbMigrator` 是迁移入口。 +- 多租户隔离不使用 PostgreSQL RLS;由 Host 租户解析、EF Query Filter、写入拦截器、PostgreSQL 约束和真实 PostgreSQL 测试共同保证。 +- 身份、短信、对象存储、支付、通知和 AI 都通过 Application 层接口表达业务意图;第三方 SDK、密钥读取和 provider 细节只允许出现在 Infrastructure。 +- 租户自定义域名由可信 Host 解析,不接受 query/header 伪造切换租户。 +- 公共题库由唯一平台主体拥有;订阅有效租户可访问公共题,租户私题只属于本租户。 -本项目按 greenfield 后端推进,不兼容旧 Supabase 数据库、旧 RLS、旧 Storage bucket 约定、旧 Refresh Token 或旧题单 JSON。旧 NestJS/Supabase 仓库只作为业务行为和接口清单参考,不再作为运行时依赖。 - -核心设计取舍: - -- PostgreSQL 只作为标准 PostgreSQL 使用,不绑定 Supabase 托管能力。 -- 数据结构由 EF Core entity、Fluent Configuration 和 Migration 管理,`Tiku.DbMigrator` 是执行迁移的入口。 -- 多租户隔离不使用 PostgreSQL RLS;通过请求租户上下文、EF Core Query Filter、写入拦截器、PostgreSQL 约束和真实集成测试共同兜底。 -- 身份、短信、对象存储、支付和通知全部通过 Application 层接口表达业务意图,第三方 SDK 和密钥读取只允许出现在 Infrastructure provider 边界。 -- 租户自定义域名由可信 Host 解析,不接受客户端通过 query/header 伪造切换租户。 -- 公共题库由唯一平台主体拥有,订阅有效租户可访问公共题,同时租户私题只属于本租户。 - -阶段设计文档: - -- [`docs/architecture/authentication-authorization-security.md`](docs/architecture/authentication-authorization-security.md)(当前认证、RBAC、DataScope、MFA、Session 与 Host 安全策略) -- [`docs/migration/phase-1-repository-baseline.md`](docs/migration/phase-1-repository-baseline.md) -- [`docs/migration/phase-2-engineering-foundation.md`](docs/migration/phase-2-engineering-foundation.md) -- [`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) -- [`docs/migration/phase-8-ai-foundation.md`](docs/migration/phase-8-ai-foundation.md) - -## 技术栈与分层 +## 技术栈与目录 - ASP.NET Core Controller API - Entity Framework Core + Npgsql - PostgreSQL - Serilog - ZLinq -- AlibabaCloud.OSS.V2 -- AlibabaCloud.SDK.Dysmsapi20170525 +- AlibabaCloud OSS / SMS SDK - Senparc.Weixin.* - AlipaySDKNet.Standard +- Microsoft Semantic Kernel(仅 Infrastructure AI 边界) - xUnit ```text -Tiku.Api # HTTP API、认证授权、OpenAPI/Scalar +Tiku.Api # HTTP API、认证授权、OpenAPI/Scalar、静态原型入口 Tiku.Application # 应用服务、用例编排、接口抽象 Tiku.Domain # 领域实体、枚举、基础类型 Tiku.Infrastructure # EF Core、PostgreSQL、外部服务实现 Tiku.DbMigrator # 数据库迁移启动项目 Tiku.Worker # 后台任务入口 Tiku.UnitTests # 单元测试 -Tiku.IntegrationTests # API / EF 模型集成测试 +Tiku.IntegrationTests # API / EF / PostgreSQL 集成测试 +docs # ADR、架构说明和迁移路线 ``` -## 相比原版的主要优势 +## 平台端静态原型 -### 1. 安全边界更清楚 - -旧版把安全逻辑分散在 Nest API、Supabase Auth/RLS、SQL policy、脚本和前端约定里。新后端改为: - -- ASP.NET Authentication 负责身份认证。 -- ASP.NET Authorization 负责权限策略。 -- JWT + 数据库 `auth_sessions` 负责 access/refresh/session 闭环。 -- `ICurrentUser` / 只读 `ITenantContext` 统一当前用户和请求租户上下文。 -- Refresh Token 采用 `v2.{t|p}.{tenantId|-}.{sessionId}.{secret}` 结构,刷新和退出先验证 realm/Host/tenant,再通过统一 Session Store 定位并撤销 token family。 -- EF Core Query Filter、写入拦截器和 PostgreSQL 组合约束共同阻断跨租户读写。 -- PostgreSQL FK / unique / check / index 负责数据完整性底线。 -- 审计事件表记录关键行为。 - -新系统不照搬 Supabase RLS、`app_private` schema 和生产防护 SQL,权限主要在应用层实现,数据库负责硬约束。 - -### 2. 多租户约束不再靠“大家小心” - -新库以多租户为一等设计: - -- 租户数据表默认带 `TenantId`。 -- 跨租户引用优先使用 composite FK,例如 `(tenant_id, id)`。 -- 业务 API 默认从当前请求上下文解析租户,不信任请求 body 里的 `tenantId`。 -- 租户域名、品牌、设置、角色、班级、学生运营都已经有独立模型。 -- `IgnoreQueryFilters()`、`FromSql`、`ExecuteSql` 和直接 `NpgsqlCommand` 只能出现在受审计基础设施边界。 - -这比旧版在 API、RLS、前端之间反复拼 tenant 条件更可控。 - -### 3. PostgreSQL 能力被正经使用 - -- JSON 字段统一使用 C# `JsonElement`,映射 PostgreSQL `jsonb`。 -- 内容树路径使用 `ltree`。 -- 域名、兑换码等大小写不敏感字段使用 `citext`。 -- 数组使用 PostgreSQL array,不塞字符串。 -- 钱相关字段使用 cents 或明确 decimal precision。 -- 关键唯一性和状态底线落数据库约束。 - -### 4. 运行时地基补齐 - -旧版对连接池、日志、跨域、限流、配置校验这些工程底座比较薄。新后端已经补上: - -- 单例 `NpgsqlDataSource` + scoped `AddDbContext`,避免租户状态跨请求复用。 -- Serilog 结构化日志和请求上下文日志。 -- 集中 CORS 配置,生产默认不放开 Origin。 -- ASP.NET RateLimiter 全局限流。 -- Options `ValidateOnStart()` 启动校验。 -- ZLinq 作为后续热路径低分配工具。 - -### 5. 公共题库和租户私库统一闭环 - -题库不再按“租户复制公共题”建模,而是拆成所有权和消费引用: - -- 唯一 `PlatformOwned` 平台主体拥有公共题库、公共题、公共题版本和公共分类主干。 -- 有效订阅租户自动访问公共题库,不通过逐题库 grant 表做主授权。 -- 租户私有题库、私有题和扩展分类只属于上传租户。 -- `TenantQuestionReference` 作为租户消费公共题或本租户私题的受控引用,禁止引用其他租户私题。 -- API 对外只暴露 `QuestionLocator { source, questionId }`,其中 `source` 只能是 `platform` 或 `tenant`。 -- `PracticeSessionQuestion` 锁定题目版本,确保公共题发布新版本后,历史答题和进行中练习仍按原版本回放。 - -### 6. 统一前端运行时 - -租户通过自定义域名访问统一托管前端,后端只信任 Host 解析结果: - -- 自定义域名走 `CNAME -> 统一前端/网关`,浏览器使用同域 `/api` 调后端。 -- `TenantResolutionMiddleware` 在认证之前解析租户;未知、Pending、禁用域名直接 404。 -- JWT tenant claim 必须和 Host 解析结果一致,否则 403。 -- `GET /api/runtime/bootstrap` 根据当前 Host 返回品牌、主题、功能开关、导航和首页模块。 -- 配置采用 Draft / Preview / Publish,公开配置禁止任意 HTML、JavaScript、外部脚本和内部密钥。 - -### 7. 外部服务不绑 Supabase - -新后端已经完全脱离 Supabase Auth / Storage 兼容层。业务层只依赖接口和统一租户 Provider 配置: - -- Identity:`IIdentityProvider`,默认自有 JWT、Session、密码、短信和微信认证。 -- SMS:`ISmsProvider`,只负责发送验证码或模板短信,验证码生成、哈希和频控仍在业务服务。 -- Object Storage:`IObjectStorageService`,默认阿里云 OSS,`local_dev` 仅用于本地测试。 -- Payment:`IPaymentProvider`,通过统一 Provider 配置加载账户和密钥。 -- Notification:`INotificationProvider`,默认站内通知持久化,不让业务代码跨模块直接 new 通知实体。 -- Provider 配置统一落 `TenantExternalProvider` + `TenantSecret`。 -- `ConfigPublic` 只保存公开字段,例如 appId、merchantId、region、endpoint、bucketAlias、templateCode。 -- 密钥只通过 `SecretRef` 关联 `TenantSecret`,禁止把 secret/token/key/privateKey 写入公开配置。 -- AI 底座开始引入 Microsoft Semantic Kernel,但包只放在 Infrastructure;租户模型 API Key 走 `TenantExternalProvider(capability=ai)` + `TenantSecret`,业务层不直接引用 SK namespace。当前优先场景是租户教师后台对话和 AI 审核题目反馈。 - -资源存储方向: - -- Infrastructure 收敛阿里云 OSS SDK 细节。 -- 对象 key 默认要求租户前缀,避免资源混放。 -- 上传签名前校验 MIME、大小和租户对象存储 provider。 -- 下载/预览必须先过业务授权,再签发临时 URL。 -- OSS HEAD metadata 用于上传确认、大小/MIME/ETag/安全扫描校验。 - -## 当前阶段成果 - -### 数据库 - -数据库模型迁移已经完成到 greenfield 初始 schema: +平台端 demo 已作为静态文件挂到 API 项目: ```text -Tiku.Infrastructure/Persistence/Migrations/20260728031410_InitialSchema.cs +GET /platform-admin/ ``` -当前 EF 模型覆盖: +它是功能原型壳,不是正式视觉规范。默认 mock 模式;联调时通过 `runtime-config.js` 注入同源 `/api`、平台 access token provider 和 API 模式。 -- 租户、用户、成员、认证、Session、短信验证码 -- 地区、模块、院校、专业、科目、分类 -- 平台公共题库、租户私有题库、题目引用和题目版本 -- 公共分类主干、租户扩展分类、内容入口、题集和练习蓝图 -- 词汇、手册、用户单词进度 -- 资源、图片、App 资源、视频解析、导入任务 -- 版本锁定练习、答题、收藏、错题、报告、统计 -- 自定义域名 DNS/TLS 生命周期和版本化前端运行时配置 -- 商品、订单、支付、权益、兑换码、优惠券 -- 统一外部 Provider 配置和租户密钥 -- 推广、CRM、佣金 -- Banner、FAQ、公告、通知、徽章、审计 -- 平台账单、催收、审计告警 -- PocketBase 导入审计 +当前不把后端改成 MVC/Razor,也不为这个静态 demo 单独维护 Node 服务。后续平台端产品化时,建议迁为独立 React/Vite/Next 工程,.NET 继续提供 API。 -### 安全与认证 +## 数据库与 ORM 分工 -已完成: +EF Core code-first migration 负责: -- ASP.NET Core Identity 密码、锁定、SecurityStamp、强制改密、TOTP 和恢复码。 -- 带 `kid` 的 RSA JWT Bearer 认证和旧公钥轮换验证。 -- tenant/platform 双 realm 与 Host、tenant claim、数据库 Session 联合校验。 -- Session family 原子 refresh、重放撤销、logout 和 logout-all。 -- 手机号/邮箱/用户名 + 密码登录、短信验证码登录和安全短信发送入口。 -- 微信网页 OAuth 和微信小程序登录,外部身份不保存 `session_key`。 -- 数据库 tenant/platform RBAC、MFA policy、资源型授权和 DataScope SQL。 -- 按有效权限生成 tenant/platform UI 菜单 bootstrap;菜单不作为 API 授权依据。 -- 租户级身份 Provider 配置解析。 -- 当前用户 `/api/me`。 -- 当前租户 `/api/tenants/current`。 -- 租户公开解析和公开配置。 -- Host 解析的前端运行时配置 `/api/runtime/bootstrap`。 -- 统一异常响应和请求日志。 +- 表、列、索引、普通外键; +- 普通 unique/check constraint; +- 模型快照和迁移历史; +- 通过 `Tiku.DbMigrator` 显式执行迁移。 -完整安全策略、Host 判定矩阵和登录/Session 文字流程图见 [`docs/architecture/authentication-authorization-security.md`](docs/architecture/authentication-authorization-security.md)。 +PostgreSQL guard 负责 EF 无法表达的跨表租户不变量: -### 已迁移 API +- `TenantQuestionReference` 只能引用平台公共题或当前租户私题; +- `TaxonomyNode` 父节点只能属于平台主体或当前租户; +- 需要读取 `tenants.mode` 或比较 owner 关系的条件约束。 -2026-07-27 运行时 OpenAPI 基线包含 192 个路径、237 个操作。已完成的业务/API 闭环包括: +维护规则: -- health -- tenant resolve / current public -- auth password / sms / wechat / refresh / logout -- me / current tenant -- catalog 基础只读 -- content navigation 只读 -- 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 +1. SQL 只放在 `Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs`。 +2. Migration 只调用 `migrationBuilder.EnsureTenantIsolationGuards()` / `DropTenantIsolationGuards()`。 +3. 重建 `InitialSchema` 后,`Up()` 末尾必须调用 `EnsureTenantIsolationGuards()`,`Down()` 开头必须调用 `DropTenantIsolationGuards()`。 +4. 新增 guard 前先判断能否用 EF FK / unique / check 表达;表达不了才加 PostgreSQL guard。 +5. 每个 guard 必须有 migration script 断言和真实 PostgreSQL 越权测试。 -## 新开发约束 - -新增业务时默认遵守以下边界: +## 开发约束 - 新增租户实体必须实现租户 marker,并通过模型测试确认 Query Filter、租户唯一索引和组合外键。 -- 普通 Controller / Service 不接受可写 `tenantId`、任意 owner tenant GUID、任意 bucket 或任意 provider 细节。 -- 题目写接口使用 `QuestionLocator`,答题接口使用 `sessionQuestionId`,不得恢复裸 `QuestionId` 练习写入。 +- 普通 Controller / Service 不接受可写 `tenantId`、任意 owner tenant GUID、任意 bucket 或 provider 密钥。 +- 题目写接口使用 `QuestionLocator`;答题接口使用 `sessionQuestionId`;不得恢复裸 `QuestionId` 练习写入。 - 自定义域名请求不得通过 `tenantCode`、`host` query 或客户端转发头切换租户。 -- 第三方 SDK、密钥读取、OSS bucket 拼接、微信/支付/短信 provider 细节只允许在 Infrastructure provider 实现中出现。 -- 不新增 Supabase provider、Supabase URL 拼接、Supabase Storage bucket 逻辑或 Supabase Auth 兼容层。 -- EF Core 管实体和 migration 生命周期;跨表租户不变量不能指望 ORM 自动推导。比如“题目引用只能指向平台或本租户”“分类父节点只能属于平台或本租户”,必须用集中 PostgreSQL trigger / constraint trigger SQL helper + migration 调用 + 真实 PostgreSQL 测试兜底,禁止去数据库手工补。 +- 业务层不得直接引用第三方 SDK namespace、拼 OSS bucket、读取微信/支付/短信/AI 密钥。 +- 不新增 Supabase provider、Supabase URL 拼接、Supabase Storage 或 Supabase Auth 兼容层。 +- AI 面向租户教师后台和题目反馈审核;租户 API Key 存 `TenantSecret`,SK 类型不得进入 Domain/Application/API。 -## 数据库边界与 ORM 分工 +## 文档入口 -本项目仍然采用 EF Core code-first migration 管理数据库基线:实体、索引、外键、普通唯一约束和普通 check constraint 都应优先通过 `IEntityTypeConfiguration` 表达,并随 migration 进入代码库。 - -但 EF Core 不会、也不应该自动推导跨表业务不变量。以下规则必须保留为 PostgreSQL guard: - -- `TenantQuestionReference` 只能引用平台主体公共题或当前租户私题,不能引用其他租户私题。 -- `TaxonomyNode` 的父节点只能属于平台主体或当前租户,不能挂到其他租户节点。 -- 需要读取 `tenants.mode` 或按 owner 关系做条件判断的规则。 - -这些 guard 的维护约定固定如下: - -1. 触发器 SQL 只放在 `Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs`。 -2. Migration 不直接复制触发器 SQL,只调用 `migrationBuilder.EnsureTenantIsolationGuards()` 或 `migrationBuilder.DropTenantIsolationGuards()`。 -3. 重建或压缩 `InitialSchema` 后,必须在 `Up()` 末尾调用 `EnsureTenantIsolationGuards()`,在 `Down()` 开头调用 `DropTenantIsolationGuards()`。 -4. 新增类似规则时,先判断能否用 EF FK / unique index / check constraint 表达;表达不了才新增 PostgreSQL guard。 -5. 每个 guard 必须同时有 migration script 断言和真实 PostgreSQL 越权测试。 - -## 还剩多少待迁移 - -运行时契约基线显示,旧 NestJS 有 342 个操作,当前 .NET 有 237 个操作: - -```text -旧版 endpoint 总量:约 342 -当前 .NET 操作:237 -``` - -两边路径设计并非逐字兼容,不能用 `342 - 237` 推算剩余工作量。详细机械比较和后续取舍入口见 [`docs/migration/contracts/operation-inventory.csv`](docs/migration/contracts/operation-inventory.csv)。旧版里有不少平台运营、商业化自动化、审计告警、积分、督导、对账等后段能力,应按新架构重新筛选。 - -当前阶段三和阶段四已经完成租户隔离、共享题库、学习闭环基础、运行时前端配置和外部服务解耦。后续不建议继续按旧 endpoint 数量机械补齐,应按业务闭环推进: - -1. 高级交易运营:退款、对账、调账凭证、支付异常处理。 -2. 租户内容导出与 Worker 骨架:导入异步化、导出任务、资源扫描、统计聚合。 -3. 平台后台基础:平台总览、租户管理、平台员工、平台公共题库运营。 -4. 平台账单、发票、催缴、审计告警。 -5. AI 推荐报告:学校推荐、报告生成、导出任务。 -6. 零散增强:视频观看进度、租户洞察、监督规则、更细 RBAC 权限点。 +- [当前认证、授权与 Host 安全策略](docs/architecture/authentication-authorization-security.md) +- [认证与授权待补强清单](docs/architecture/authentication-authorization-hardening-plan.md) +- [迁移路线与剩余范围](docs/migration-roadmap.md) +- [API 契约基线说明](docs/migration/contracts/README.md) +- [AI 底座设计](docs/migration/phase-8-ai-foundation.md) ## 常用命令 @@ -273,7 +101,7 @@ dotnet format TIKU-BACKEND.slnx --verify-no-changes --no-restore git diff --check ``` -生成数据库 SQL: +生成迁移 SQL: ```bash dotnet ef migrations script \ @@ -281,7 +109,15 @@ dotnet ef migrations script \ --startup-project Tiku.DbMigrator ``` -首次部署可在迁移完成后一次性创建平台超级管理员: +检查模型是否有未生成 migration 的变更: + +```bash +dotnet ef migrations has-pending-model-changes \ + --project Tiku.Infrastructure \ + --startup-project Tiku.DbMigrator +``` + +首次部署可在迁移完成后创建平台超级管理员: ```bash export TIKU_BOOTSTRAP_PLATFORM_ADMIN_EMAIL='admin@example.com' @@ -290,4 +126,4 @@ export TIKU_BOOTSTRAP_PLATFORM_ADMIN_NAME='Platform Administrator' dotnet run --project Tiku.DbMigrator -- --bootstrap-platform-admin ``` -该命令只允许在不存在任何平台角色用户绑定时执行。创建的账号必须在首次登录时修改临时密码并完成 TOTP MFA 注册;检测到已有平台管理员时命令会拒绝重复引导。不要把临时密码写入仓库配置或命令行参数。 +该命令只允许在不存在任何平台角色用户绑定时执行。不要把临时密码写入仓库配置或命令行参数。 diff --git a/docs/adr/0001-authoritative-dotnet-backend.md b/docs/adr/0001-authoritative-dotnet-backend.md index bb934d9..d81fdac 100644 --- a/docs/adr/0001-authoritative-dotnet-backend.md +++ b/docs/adr/0001-authoritative-dotnet-backend.md @@ -5,28 +5,28 @@ ## 背景 -项目已有一套 NestJS 后端,数据库、认证和存储与 Supabase 的部署及规则耦合较深。新的 ASP.NET Core 后端已经形成完整解决方案、EF Core 模型、迁移链和大量业务 API。前后端尚未进入正式开发,当前仍处于可以直接收敛技术路线的窗口。 +旧 NestJS 后端与 Supabase 数据库、认证、存储和规则耦合较深。新的 ASP.NET Core 后端已经具备 EF Core 模型、迁移链和业务 API。前后端尚未正式开发,仍可直接收敛技术路线。 ## 决策 1. 本仓库是题库 SaaS 唯一继续演进的后端。 -2. 旧 NestJS 仓库冻结为业务行为、接口契约和数据迁移参考;除迁移阻断问题外,不再承接新功能。 -3. EF Core Migration 是普通数据库结构变更的唯一权威历史。生产环境通过 `Tiku.DbMigrator` 显式执行经审查的迁移,API 不在启动时自动同步结构。 -4. 应用只依赖标准 PostgreSQL 能力和 Npgsql,不再以 Supabase 作为运行时依赖或托管目标。 -5. 身份、对象存储、短信、支付和通知通过 Application 层接口隔离供应商,并通过租户级 `TenantExternalProvider` + `TenantSecret` 配置;不建设 Supabase Auth/Storage 兼容层。 -6. Yudao 不作为运行时依赖,只参考其 RBAC、租户套餐、审计和后台产品设计。 -7. 新功能按完整领域闭环迁移和验收,不以 Controller 或 endpoint 数量作为完成标准。 +2. 旧 NestJS 仓库冻结为业务行为、接口契约和数据迁移参考。 +3. EF Core Migration 是普通数据库结构变更的权威历史;生产通过 `Tiku.DbMigrator` 显式执行迁移,API 不自动同步结构。 +4. 应用只依赖标准 PostgreSQL 和 Npgsql,不以 Supabase 作为运行时依赖或托管目标。 +5. 身份、对象存储、短信、支付、通知和 AI 通过 Application 接口隔离供应商,并通过 `TenantExternalProvider` + `TenantSecret` 配置。 +6. Yudao 只参考 RBAC、租户套餐、审计和后台产品设计,不作为运行时依赖。 +7. 新功能按业务闭环迁移和验收,不以 Controller 或 endpoint 数量作为完成标准。 -## 迁移边界 +## 边界 - 认证和授权由 ASP.NET Core、JWT、数据库 Session 和应用权限策略负责。 - 多租户隔离由请求租户上下文、EF 查询/写入防护、数据库约束和真实 PostgreSQL 测试共同负责。 -- 普通表、列、索引、约束由 EF Core Migration 管理;确有必要的 PostgreSQL 专用 SQL 可以包含在 Migration 中并接受审查。 -- 旧 OpenAPI 用于发现能力缺口,不要求新接口逐字兼容。任何主动不兼容都必须在迁移清单中记录决定和替代接口。 +- 普通表、列、索引、外键和普通约束由 EF Core Migration 管理。 +- EF 不能表达的跨表租户不变量使用集中 PostgreSQL guard,由 migration 调用并接受测试。 +- 旧 OpenAPI 用于发现能力缺口,不要求新接口逐字兼容。 ## 结果 -- 不再建设长期双后端或双写链路。 -- 不再保留 Supabase 作为认证、存储或数据库运行时备选目标。 +- 不建设长期双后端或双写链路。 +- 不保留 Supabase 作为认证、存储或数据库运行时目标。 - 更换 PostgreSQL 托管商或外部服务供应商不要求重写业务代码。 -- 后续开发先补真实 PostgreSQL 测试、强制租户边界、生产配置 fail-fast 和敏感配置加密,再扩展业务模块。 diff --git a/docs/architecture/authentication-authorization-hardening-plan.md b/docs/architecture/authentication-authorization-hardening-plan.md index 6b69640..b317bb6 100644 --- a/docs/architecture/authentication-authorization-hardening-plan.md +++ b/docs/architecture/authentication-authorization-hardening-plan.md @@ -1,415 +1,85 @@ -# TIKU SaaS 认证与授权补强计划 +# 认证与授权待补强清单 -状态:待实施 +当前生效规则见 [认证、授权与 Host 安全策略](authentication-authorization-security.md)。本文只记录尚需补强的安全事项,不重复描述已实现体系。 -制定日期:2026-07-28 +## P0:可信代理与 Host fail-closed -适用范围:`Tiku.Api`、`Tiku.Application`、`Tiku.Infrastructure`、`Tiku.Worker` 及相关 PostgreSQL 集成测试。 +- Production 必须配置正式 `PlatformHosts` 和 `TrustedProxyAddresses`。 +- Production 不允许只保留 `localhost` / `127.0.0.1` 作为平台 Host。 +- 未受信来源伪造 `X-Forwarded-Host` 不能改变 realm 或 tenant context。 +- 受信代理只接受一跳转发,网关必须覆盖客户端伪造的 Forwarded Headers。 +- API 公网入口必须只能由受信网关访问。 -本文档是在现有《TIKU SaaS 认证、授权与 Host 安全策略》基础上制定的实施计划。现有体系已经具备 ASP.NET Core Policy-based Authorization、数据库 Session、tenant/platform realm、数据库 RBAC、MFA、DataScope、EF Core tenant query filter、写入拦截器和 PostgreSQL 约束。本计划不推翻这些边界,而是按风险优先级补齐生产安全、SaaS 套餐授权、最小权限验证、特权审计和客户端认证规范。 +验收: -## 1. 目标与原则 +- Host A + Tenant B token 返回 403。 +- 未知 Host 的非豁免路径返回 404。 +- Production 缺少可信代理或正式平台 Host 时启动失败。 -补强后的每次授权应形成以下闭环: +## P0:Disabled / Invited 成员生命周期 + +- 外部身份登录不得静默恢复 Disabled membership。 +- Invited membership 不得被微信登录静默激活。 +- 首次外部登录是否允许创建学生成员,必须由租户自注册策略控制。 +- 成员恢复只能由管理员显式操作并写审计。 + +验收: + +- Disabled 成员旧 access/refresh 立即失效。 +- Disabled 成员不能通过微信 Web 或小程序登录恢复。 +- 关闭自注册时,首次外部登录被拒绝。 + +## P1:SaaS Capability 授权 + +RBAC 只回答“用户是否有操作权限”;Capability 负责“租户是否购买、启用并可使用该能力”。 + +默认组合: ```text -User Active - + Session Active - + Host / Realm / Tenant 一致 - + Tenant Active - + Membership Active +Tenant Active + Subscription 有效 - + 套餐包含目标模块 + + Module / Feature 可用 + Operation Permission + DataScope / Resource Scope - + 敏感操作 MFA + + 必要时 MFA ``` -实施遵循以下原则: - -1. 先处理可导致安全边界绕过或权限生命周期失效的问题,再扩展产品能力。 -2. 所有安全配置默认 fail-closed,不能只依赖部署文档提醒。 -3. JWT 继续只表达已认证会话,不承载可直接授权的角色和权限。 -4. 前端菜单、feature flag 和页面隐藏永远不能代替 API 授权。 -5. 列表、详情、写入、批量、导出和异步任务使用一致的数据范围。 -6. 每个阶段独立实现、独立测试、独立提交;未通过本阶段验收不得进入下一阶段。 - -## 2. 优先级与交付顺序 - -| 优先级 | 阶段 | 目标 | 预计工作量 | 发布要求 | -| --- | --- | --- | ---: | --- | -| P0 | 可信代理与 Host 边界 | 阻止伪造 Forwarded Host 改变安全上下文 | 1~2 天 | 生产前必须完成 | -| P0 | 成员禁用生命周期 | 禁止外部登录恢复 Disabled 成员 | 0.5~1 天 | 生产前必须完成 | -| P1 | SaaS Capability 授权 | 统一套餐、模块、权限、DataScope 和 MFA | 3~5 天 | SaaS 商业化前必须完成 | -| P1 | 接口最小权限审计 | 防止过宽 policy 和 DataScope 漏检 | 2~4 天 | 新后台全面接入前完成 | -| P1 | System Scope 审计 | 约束并持久化跨租户特权操作 | 2~3 天 | Worker/平台操作上线前完成 | -| P2 | Token、隐私与协议规范 | 降低浏览器、PII 和自定义协议风险 | 3~7 天 | 正式客户端规模化前完成 | - -推荐交付顺序: - -```text -可信代理 - -> Disabled 成员 - -> Capability / 套餐授权 - -> 接口权限清单与 DataScope - -> System Scope 审计 - -> 浏览器 Token、PII 和 OIDC 演进 -``` - -## 3. P0:收紧可信代理和 Host 边界 - -### 3.1 风险 - -系统把 Host 作为 tenant/platform realm 的安全上下文,但当前 Forwarded Headers 配置会清空框架默认的 `KnownProxies` 和 `KnownIPNetworks`。如果生产环境没有提供有效 `TrustedProxyAddresses`,应用不会以 fail-closed 方式拒绝非可信来源提供的 `X-Forwarded-Host`。 - -### 3.2 实施范围 - -1. 为 `TenantResolutionOptions` 增加生产配置校验: - - Production 必须配置至少一个正式 `PlatformHosts`。 - - Production 必须配置至少一个合法 `TrustedProxyAddresses`。 - - Production 不得只保留 `localhost` 或 `127.0.0.1` 作为 Platform Host。 - - 代理地址无效时应用启动失败。 -2. 调整 `ForwardedHeadersOptions`: - - 只有存在合法可信代理配置时,才替换框架默认 proxy/network 限制。 - - 保持 `ForwardLimit = 1`。 - - 显式配置 `AllowedHosts`。 - - 只处理部署所需的 `X-Forwarded-For`、`X-Forwarded-Host` 和 `X-Forwarded-Proto`。 -3. 收紧 Host 配置: - - 生产 `AllowedHosts` 不允许使用 `*`。 - - Platform Host、租户公共主域名和允许的网关 Host 必须形成可审计配置。 -4. 固化网关契约: - - 网关覆盖客户端提供的 Forwarded Headers。 - - API 监听端口不可绕过网关直接暴露公网。 - - 网络 ACL 只允许受信网关访问 API。 - -### 3.3 自动化验收 - -- 未受信来源伪造 Platform Host,不能进入 platform realm。 -- 未受信来源伪造其他租户 Host,不能改变 tenant context。 -- 受信代理转发 Active 租户域名,可以解析正确租户。 -- Tenant A Host 携带 Tenant B token,返回 403。 -- 未知 Host 的非豁免路径返回 404。 -- Production 缺少 Platform Host 或可信代理时启动失败。 -- 伪造 Forwarded Headers 的测试必须经过完整 HTTP middleware pipeline,不能只单测 `TenantResolutionMiddleware`。 - -### 3.4 建议提交 - -```text -fix(security): fail closed on forwarded host trust -``` - -## 4. P0:修正 Disabled 成员生命周期 - -### 4.1 固定状态语义 - -| Membership 状态 | 登录行为 | 状态改变方式 | -| --- | --- | --- | -| Active | 允许继续认证 | 正常业务流程 | -| Invited | 不得静默激活 | 显式接受邀请或管理员确认 | -| Disabled | 所有登录方式拒绝 | 仅管理员显式恢复 | -| 不存在 | 根据租户自注册策略决定 | 创建新 Student 或拒绝 | - -### 4.2 实施范围 - -1. 外部身份登录只能创建从未存在过的 Student membership,不能恢复历史 membership。 -2. 查询到 Disabled membership 时返回统一租户访问拒绝,不能改回 Active。 -3. 查询到 Invited membership 时进入显式邀请接受流程,不能由微信登录静默激活。 -4. 建议增加租户级注册策略: - - `AllowStudentSelfRegistration` - - `AllowedSelfRegistrationProviders` - - `RequireRegistrationApproval` -5. 成员恢复必须复用管理员权限、DataScope 和审计,不允许隐藏在登录流程里。 - -### 4.3 自动化验收 - -- Disabled Student 不能通过微信 Web 或小程序登录恢复状态。 -- Disabled Student 的旧 access token 和 refresh token 继续立即失效。 -- Invited 成员不会被外部身份登录静默转为 Active。 -- 允许自注册时,首次微信登录可以创建 Student membership。 -- 关闭自注册时,首次微信登录被拒绝。 -- 管理员显式恢复并写入审计后,成员才能重新登录。 - -### 4.4 建议提交 - -```text -fix(auth): preserve disabled tenant membership state -``` - -## 5. P1:建立统一 SaaS Capability 授权层 - -### 5.1 目标 - -现有 RBAC 继续负责“用户是否有操作权限”,新增 Capability 层负责“租户当前是否购买、启用并可使用该能力”。两者必须同时成功。 - -### 5.2 Application 抽象 - -建议在 Application 层新增只读上下文: - -```csharp -public interface ICurrentTenantCapabilityContext -{ - Task GetAsync( - CancellationToken cancellationToken = default); -} -``` - -Snapshot 至少表达: - -- `TenantId` -- `SubscriptionStatus` -- `SubscriptionExpiresAt` -- `EnabledModules` -- `FeatureFlags` -- `UsageLimits` -- `IsReadOnly` -- `DenialReason` - -建议增加以下 Authorization Requirement: - -- `TenantSubscriptionRequirement` -- `TenantModuleRequirement(moduleCode)` -- `TenantUsageRequirement(resourceCode)` - -### 5.3 Policy 组合 - -Controller 不得分别手工检查套餐和权限。现有 permission policy 应由统一注册器或自定义 policy provider 组合: - -```text -tenant:content:manage - = CurrentTenantMember - + ActiveSubscription - + TenantModule(content) - + TenantPermission(tenant:content:manage) - + MFA -``` - -平台权限和租户权限继续保持 realm 隔离。平台管理员不能因为拥有平台权限而自动读取租户业务数据,跨租户操作必须进入受控 System Scope。 - -### 5.4 订阅状态语义 - -实施前固定以下默认规则,产品有不同要求时必须在代码和测试中显式调整: - -| 订阅状态 | 历史读取 | 新增业务数据 | 后台配置 | -| --- | --- | --- | --- | -| Trial | 允许 | 允许,受额度限制 | 允许 | -| Active | 允许 | 允许 | 允许 | -| PastDue | 允许 | 默认拒绝或只读 | 仅账单相关 | -| Cancelled | 允许导出和历史查询 | 拒绝 | 仅账单和迁出 | -| Expired | 按保留期只读 | 拒绝 | 仅恢复订阅 | - -租户不存在、模块未购买、订阅失效和额度耗尽应使用不同内部错误码,不能全部退化成同一个未找到响应。 - -### 5.5 自动化验收 +验收: - 有 permission 但套餐不含模块,返回 403。 - 套餐包含模块但没有 permission,返回 403。 -- 套餐和 permission 都有效时允许访问。 -- PastDue、Cancelled 或 Expired 不能创建新的受限资源。 -- 修改套餐或模块后,旧 access token 不需要等待过期即可失去能力。 -- 前端 feature flag 关闭或开启都不能绕过后端 module requirement。 -- 公共题库订阅规则继续通过统一 Capability 或受控领域 policy 执行。 +- PastDue / Cancelled / Expired 不能创建新的受限资源。 +- 修改套餐后,旧 access token 不需要等待过期即可失去能力。 -### 5.6 建议提交 - -```text -feat(authz): enforce tenant subscription capabilities -``` - -## 6. P1:接口最小权限与 DataScope 审计 - -### 6.1 接口授权清单 - -建立代码化或可由测试读取的 endpoint authorization manifest,每个接口至少记录: - -- HTTP method 与 route -- tenant/platform realm -- 是否允许匿名 -- module code -- permission code -- DataScope 策略 -- 是否要求 MFA -- 审计 action - -示例: - -| 路由 | Realm | 模块 | Permission | DataScope | -| --- | --- | --- | --- | --- | -| `POST /tenant/students` | tenant | student | `tenant:student:manage` | Region/Class | -| `PUT /tenant/providers` | tenant | provider | `tenant:provider:manage` | All-only | -| `GET /platform/tenants` | platform | platform-admin | `platform:tenant:manage` | Platform | -| `POST /content/questions` | tenant | content | `tenant:content:manage` | Owner/Region | - -### 6.2 架构测试 +## P1:接口最小权限与 DataScope 审计 +- 建立 endpoint authorization manifest:method、route、realm、module、permission、DataScope、MFA、audit action。 - 后台写接口不得只使用 `[Authorize]`。 -- 后台管理接口不得只使用 `CurrentTenantMember`。 -- tenant Controller 不得引用 platform permission,反之亦然。 -- permission 必须属于 endpoint 声明的 module。 -- All-only 操作必须显式声明,不能隐式放宽。 -- `[AllowAnonymous]` 只能出现在审核后的白名单路由。 +- tenant/platform 权限不得串用。 +- `[AllowAnonymous]` 只能出现在白名单路由。 +- All-only 资源必须显式声明。 - 新增 Controller action 未进入 manifest 时测试失败。 -- `TenantResourceAccessRequirement` 不能单独代替 operation permission 和 MFA。 -### 6.3 DataScope 测试矩阵 +验收: -每类资源至少覆盖: +- 列表、详情、创建、更新、删除、批量、导出和 Worker job 使用一致 DataScope。 +- 租户 A 管理员不能读取或操作租户 B 数据。 -- 列表过滤 -- 单条详情 -- 创建和目标归属验证 -- 更新 -- 删除 -- 批量操作 -- 导出 -- Worker 或后台任务异步执行 +## P1:System Scope 审计 -必须证明“列表不可见的资源不能通过已知 ID 更新、删除或导出”。无法可靠映射 owner、region 或 class 的资源继续采用 All-only fail-closed。 +- `ITenantExecutionScope` 创建 System Scope 时必须记录 caller、reason、target tenant 和 request/job id。 +- 平台操作、Worker、迁移验证和受审计公共题库服务才允许使用 System Scope。 +- 跨租户写操作必须落 `AuditLog`。 -### 6.4 交付方式 +验收: -按模块拆分窄提交,例如 student、content、commerce、CRM、platform-admin,避免一次性修改所有 Controller 和 Service。 +- 未声明 reason 的 System Scope 创建失败。 +- Worker scope 不串租户。 +- 高风险平台操作都有审计记录。 -## 7. P1:收紧 System Scope 并持久化审计 +## P2:客户端与协议规范 -### 7.1 结构化上下文 - -用结构化请求代替单一 reason 字符串,至少包含: - -- `Operation` -- `TargetTenantId` -- `ActorUserId` 或 Worker identity -- `JobId` -- `CorrelationId` -- `Reason` -- `RequestedAt` - -### 7.2 持久化审计 - -System Scope 进入和退出都写数据库: - -- `system_scope.entered` -- `system_scope.completed` -- `system_scope.failed` - -失败操作也必须保留审计。日志用于可观测性,数据库审计用于业务追溯,二者不能互相替代。 - -### 7.3 调用边界 - -- Controller 不得直接注入 `ITenantExecutionScope`。 -- 普通 tenant service 不得取得全局 System Scope。 -- 仅平台管理、Worker、迁移、公共题库和明确审核的基础设施服务可以使用。 -- 架构测试维护允许调用方白名单。 -- 有明确目标租户的操作必须提供 `TargetTenantId`;只有真正的全局任务可以为空。 - -### 7.4 自动化验收 - -- 普通请求 scope 不能切换 tenant。 -- System Scope 必须创建新的 DI scope。 -- System Scope 仍不能修改已有实体的 TenantId。 -- 成功和失败操作都有持久化审计。 -- 平台 actor、目标租户、job、reason 和 trace 信息完整。 -- 未在白名单中的服务引用 `ITenantExecutionScope` 时架构测试失败。 - -### 7.5 建议提交 - -```text -feat(audit): persist privileged tenant scope operations -``` - -## 8. P2:Token、隐私与协议规范 - -### 8.1 客户端认证策略 - -按客户端明确契约: - -- App、小程序:可以继续使用 JSON access/refresh token。 -- 浏览器后台:优先评估 BFF 或 `HttpOnly + Secure + SameSite` Cookie。 -- 若浏览器继续使用 Bearer token,必须明确 refresh token 存储、CSP、XSS 防护和清理策略。 -- Cookie 与 Bearer 并存时必须配置清晰的 authentication scheme 选择规则。 - -### 8.2 Token 最小化 - -Access token 原则上只保留: - -```text -sub sid jti iat iss aud exp scope tid amr -``` - -手机号和邮箱不是授权必需 claim,客户端可以通过 `/api/me` 获取,避免 token 泄漏扩大 PII 暴露。 - -### 8.3 登录审计隐私 - -- 手机号和邮箱采用脱敏展示。 -- 使用服务端 HMAC 摘要支持稳定关联分析,不直接依赖完整 identifier。 -- 定义登录事件、IP、User-Agent 的保留期限和清理任务。 -- 限制查询和导出审计日志的权限。 -- 审计导出和清理本身也要记录审计。 - -### 8.4 OIDC 演进条件 - -短期可以保留当前第一方 JWT/Session 体系。出现以下任一需求时,应采用标准 Identity Provider 或 OpenIddict 等 OIDC/OAuth 方案,而不是继续扩展自定义协议: - -- 第三方应用接入 -- 企业 SSO -- Authorization Code + PKCE -- 标准 discovery/JWKS -- 多 API audience -- 联邦身份 -- service-to-service client credentials - -## 9. 测试与发布门槛 - -完成补强后,生产发布必须满足: - -1. `dotnet test TIKU-BACKEND.slnx` 全量通过。 -2. PostgreSQL 特有行为由真实 PostgreSQL 集成测试证明,不能使用 EF InMemory 替代。 -3. Production 缺少 JWT key、Data Protection 证书、短信 pepper、Platform Host 或可信代理时启动失败。 -4. 所有 Controller action 都进入 endpoint authorization manifest。 -5. 每个后台模块至少覆盖无权限、无套餐、跨租户、越 DataScope 和无 MFA 的负向测试。 -6. Disabled 用户、成员、租户、角色和 permission 撤销能立即使旧 Session 失效。 -7. Forwarded Host 伪造测试通过。 -8. Refresh token 并发、轮换和重放撤销测试继续通过。 -9. System Scope 全部调用点通过架构白名单,并产生持久化审计。 -10. 迁移 SQL 已人工检查 tenant-qualified foreign key、唯一约束、check constraint 和新增审计结构。 -11. 执行 `dotnet format TIKU-BACKEND.slnx --verify-no-changes --no-restore`。 -12. 执行 `git diff --check`。 - -## 10. 阶段验收与版本划分 - -### 安全基线版本 - -完成: - -- P0 可信代理与 Host 边界 -- P0 Disabled 成员生命周期 - -该版本解决已识别的直接安全边界和状态恢复风险。 - -### SaaS 授权完整版本 - -完成: - -- P1 Capability / 套餐授权 -- P1 endpoint manifest 与 DataScope 审计 -- P1 System Scope 持久化审计 - -该版本形成套餐、模块、操作权限、数据范围和特权操作的完整 SaaS 授权闭环。 - -### 客户端与合规版本 - -完成: - -- P2 浏览器认证策略 -- Token claim 最小化 -- PII 保留与脱敏 -- OIDC 演进决策 - -该版本用于正式客户端规模化、第三方接入和后续合规治理。 - -## 11. 明确不在本计划内的事项 - -- 不引入 PostgreSQL RLS;继续使用请求租户上下文、EF Query Filter、写入拦截器、组合约束和真实 PostgreSQL 测试。 -- 不把 tenant/platform permission 放入 JWT 作为直接授权事实。 -- 不允许前端菜单或 feature flag 成为唯一授权依据。 -- 不因为补强认证体系而重新引入 Supabase 运行时依赖。 -- 不在没有第三方接入需求时立即重写为完整 OIDC Provider。 +- 浏览器 token 存储策略在正式前固定:纯 Bearer、本域 BFF 或 cookie 方案只能选一种主链路。 +- Access token 继续短期有效,不把角色和权限写入 JWT。 +- 登录审计和错误响应避免泄露手机号、openId、邮箱完整值。 +- 出现第三方生态登录、开放 API 或多客户端授权需求时,再评估 OpenIddict / OIDC,不继续扩展私有协议。 diff --git a/docs/architecture/authentication-authorization-security.md b/docs/architecture/authentication-authorization-security.md index 473edfc..ef7bf2d 100644 --- a/docs/architecture/authentication-authorization-security.md +++ b/docs/architecture/authentication-authorization-security.md @@ -1,449 +1,128 @@ -# TIKU SaaS 认证、授权与 Host 安全策略 +# 认证、授权与 Host 安全策略 -本文档描述 TIKU Backend 当前生效的安全架构,是认证、后台授权、租户隔离、Host 解析、Session、MFA 和短信验证码实现的统一约定。新增接口或修改登录流程时,应以本文档和自动化测试为准,不能只依赖前端菜单、JWT 字符串或历史 `TenantRole` 约定。 +本文是当前生效安全规范。新增接口或修改登录流程时,以本文档和自动化测试为准;前端菜单、JWT 字符串和历史角色约定不能代替 API 授权。 -## 1. 安全目标与基本原则 +## 授权域 -系统同时存在两个互相隔离的授权域: +- `tenant`:租户业务域,必须绑定 Active 租户和 Active `TenantMembership`。 +- `platform`:平台运营域,只能从配置的 Platform Host 进入,不绑定租户。 -- `tenant`:租户业务域,必须绑定一个 Active 租户和一个 Active `TenantMembership`。 -- `platform`:平台运营域,不绑定租户,只能从配置的 Platform Host 进入。 +核心规则: -核心原则: - -1. Host、JWT scope、tenant claim、数据库 Session 和请求租户上下文必须一致。 -2. JWT 只证明一次已认证会话,不承载可直接授权的角色或权限。 -3. 后台权限每次从数据库角色绑定解析,菜单只负责 UI 展示,不负责 API 授权。 -4. 数据权限必须进入 SQL;无法可靠映射 owner、region 或 class 的资源采用 `All`-only fail-closed,不猜测数据归属。 -5. 用户、成员、租户、后台角色、后台权限、SecurityStamp 或 Session 任一失效,旧 token 都不能继续扩大访问权。 -6. 所有 Controller 默认要求认证,公开接口必须显式标记 `[AllowAnonymous]`。 - -整体边界如下: +- Host、JWT scope、tenant claim、数据库 Session 和请求租户上下文必须一致。 +- JWT 只证明已认证会话,不承载可直接授权的角色或权限。 +- 后台权限每次从数据库角色绑定解析;菜单只控制 UI 展示。 +- 数据权限必须进入 SQL;无法可靠映射 owner、region 或 class 的资源采用 All-only fail-closed。 +- 用户、成员、租户、后台角色、权限、SecurityStamp 或 Session 任一失效,旧 token 不能继续取得能力。 +- Controller 默认要求认证;公开接口必须显式 `[AllowAnonymous]`。 ```text -浏览器 / App - | - | HTTPS + Host + Bearer/refresh/challenge - v -可信反向代理 - | - | 仅 TrustedProxyAddresses 可以提供 Forwarded Headers - v -TenantResolutionMiddleware - | - +--> Platform Host --------> platform realm(不得出现 TenantId) - | - +--> Active Tenant Host ---> tenant realm(锁定 Host 对应 TenantId) - | - +--> Unknown Host ---------> 非豁免路径 404 - v -JWT + 数据库 AuthSession 校验 - v -IAuthorizationHandler + ICurrentAccessContext - v -EF tenant filter + DataScope SQL + PostgreSQL 约束 +Client + -> Trusted proxy + -> TenantResolutionMiddleware + -> JWT + AuthSession validation + -> Authorization handler + current access context + -> EF tenant filter + DataScope SQL + PostgreSQL constraints ``` -## 2. 账号安全底座 +## 账号与 Session -账号由 ASP.NET Core Identity 管理,`User` 继承 `IdentityUser`,Identity 与业务实体共用 `TikuDbContext`。系统不使用 ASP.NET 全局 Role 表,租户与平台后台角色由独立 SaaS RBAC 表维护。 - -当前固定参数: - -- 密码最少 10 位。 -- Identity PBKDF2 迭代次数为 210,000。 +- 账号由 ASP.NET Core Identity 管理。 +- 密码最少 10 位;PBKDF2 迭代次数 210,000。 - 连续 5 次密码失败后锁定 15 分钟。 -- 用户 Active 状态在每次 Session 校验时检查;强制改密、退出全部设备和其他账号安全事件同时通过 SecurityStamp 使旧 Session 失效。 -- TOTP、恢复码、Authenticator Key 使用 Identity 标准能力。 -- 微信等外部身份只保留 provider subject、openid、unionid 等映射,不保存 `session_key` 或原始 secret。 +- TOTP、恢复码和 Authenticator Key 使用 Identity 标准能力。 +- 微信等外部身份只保存 provider subject、openid、unionid,不保存 `session_key` 或原始 secret。 +- Data Protection key 持久化到 PostgreSQL;非 Development 环境必须提供带私钥的 PKCS#12 证书保护 key ring。 -Identity 的 Data Protection key 持久化到 PostgreSQL。Development 可以不使用证书;非 Development 环境必须提供包含私钥的 PKCS#12 证书保护 key ring,否则 API 启动失败。 +Access token: -## 3. JWT 与数据库 Session +- RSA SHA-256 签名,Header 必须包含 `kid`。 +- 固定 15 分钟。 +- 包含 `sub`、`sid`、`jti`、`iat`、`iss`、`aud`、`exp`、`scope`。 +- tenant token 必须包含 `tid`;platform token 禁止包含 `tid`。 +- 完成 MFA 的 Session 可包含 `amr=mfa`。 +- 不包含 role 或 permission claim。 -### 3.1 Access token - -Access token 使用 RSA SHA-256 非对称签名,Header 必须包含可识别的 `kid`。生产配置包含当前私钥;轮换期间把仍需验证的旧公钥放入 `PublicKeys`。未知 `kid`、错误签名、弱于 2048 位的 RSA key、把当前 `kid` 重复放入旧公钥集合等配置都会被拒绝。 - -Access token 固定 15 分钟,并包含: - -| Claim | 含义 | -| --- | --- | -| `sub` | Identity User ID | -| `sid` | 当前 `AuthSession` ID | -| `jti` | 当前 access token 的唯一 ID | -| `iat` | 签发时间 | -| `iss` / `aud` / `exp` | issuer、audience 和过期时间 | -| `scope` | `tenant` 或 `platform` | -| `tid` | 仅 tenant token 必须包含;platform token 禁止包含 | -| `amr=mfa` | 当前 Session 已完成 MFA 时包含 | - -JWT 不包含用于 API 授权的 role 或 permission claim。 - -### 3.2 AuthSession - -`auth_sessions` 是 access/refresh 的服务端事实来源,关键字段包括: - -- `realm`、可空 `tenant_id`、`user_id`; -- `token_family_id`、`parent_session_id`、`replaced_by_session_id`; -- refresh token hash、SecurityStamp、MFA 状态; -- expires/revoked 时间与 revoked reason。 - -数据库 check constraint 保证 tenant Session 必须有 `tenant_id`,platform Session 不得有 `tenant_id`。业务代码只能通过 `IAuthSessionStore` 访问 Session。 - -每次 Bearer token 验证都必须同时确认: - -```text -RSA 签名/kid/iss/aud/exp - | - v -sub + sid + jti + iat + scope/tid 结构正确 - | - v -Host realm 与 scope/tid 一致 - | - v -AuthSession 存在、未撤销、未过期 - | - v -Session.user/tenant/realm/MFA 与 JWT 一致 - | - v -User Active + SecurityStamp 一致 - | - v -tenant: Tenant Active + Membership Active -platform: 仍有有效平台后台权限 - | - v -进入 Authorization Handler -``` - -Session 校验没有配置旁路;不能通过关闭选项把 JWT 降级为纯无状态 token。 - -### 3.3 Refresh、logout 与重放 - -Refresh token 格式为: +Refresh token: ```text v2.{t|p}.{tenantId|-}.{sessionId}.{64-byte-random-secret} ``` -数据库只保存完整 refresh token 的 SHA-256 hash,明文只返回客户端一次。 +- 数据库只保存完整 refresh token 的 SHA-256 hash。 +- 刷新在事务内轮换 Session。 +- 并发刷新只允许一个成功。 +- 已轮换 token 被复用时视为重放,撤销整个 token family 并写审计。 +- logout 撤销当前 refresh token family;logout-all 更新 SecurityStamp 并撤销用户全部 Session。 -刷新在事务内完成: +## Host 与 tenant 解析 -```text -旧 refresh token - | - v -读取并验证当前 Session/用户/realm/tenant/SecurityStamp - | - v -原子设置 revoked=rotated + replacedBySessionId - | - v -创建同 family 的子 Session,返回新 access/refresh -``` +Host 是认证上下文,不是普通参数。`TenantResolutionMiddleware` 在 Authentication 前执行。 -并发刷新只允许一个请求成功。已轮换 token 被再次使用时视为重放,整个 token family 被撤销并写入审计。 - -- `POST /api/auth/logout`:撤销 refresh token 所属 family。 -- `POST /api/auth/logout-all`:更新 SecurityStamp,并撤销用户全部 Session。 -- 成员禁用、租户暂停、用户禁用、后台权限撤销后,旧 access/refresh 均不能继续取得对应后台能力。 - -## 4. Host 与 realm 安全策略 - -Host 不是普通路由参数,而是认证上下文的一部分。Host 在 Authentication 之前由 `TenantResolutionMiddleware` 解析。 - -### 4.1 Host 类型 - -| 请求入口 | 租户上下文 | 允许的认证域 | 结果 | +| 请求入口 | 租户上下文 | 允许 realm | 默认结果 | | --- | --- | --- | --- | -| 配置的 Platform Host | 默认无租户 | platform;部分白名单路径可显式提供 tenantCode 进入 tenant | 继续处理 | -| Active 租户自定义 Host | 固定为该 Host 对应租户 | tenant | 继续处理 | -| 租户 Host + 不同 tenantCode/header | Host 与输入冲突 | 无 | 403 | +| Platform Host | 无租户 | platform;白名单入口可用 tenantCode 引导 tenant 登录 | 继续 | +| Active 租户 Host | Host 绑定租户 | tenant | 继续 | +| 租户 Host + 其他 tenantCode/header | 冲突 | 无 | 403 | | Platform Host + platform realm + tenantCode | 非法混合 | 无 | 400 | -| 非 Platform、未绑定租户的未知 Host | 无 | 无 | 非豁免路径 404 | -| 未知 Host 上的 platform 登录/refresh/logout/MFA challenge | 无 | 无 | 默认先由 Host 解析返回 404;即使路径被配置为豁免,平台 Host 二次校验仍返回 400 | +| 未知 Host | 无 | 无 | 非豁免路径 404 | +| Pending/禁用域名 | 无 | 无 | 404 | -默认 Platform Host 是 `localhost` 和 `127.0.0.1`,生产必须通过 `Tenancy:Resolution:PlatformHosts` 配置正式平台域名。 +规则: -### 4.2 租户 Host 流程演示 +- 自定义域名不接受 `tenantCode`、`host` query 或客户端转发头覆盖。 +- tenant JWT 的 `tid` 必须与 Host 解析租户一致。 +- platform JWT 不能访问租户 Host。 +- 平台 Host 上的租户登录引导才允许受控使用 `tenantCode`。 +- 只接受可信代理写入的 Forwarded Headers;直连客户端伪造无效。 -假设 `school-a.example.com` 已绑定 Tenant A: +## RBAC、菜单与 DataScope -```text -GET https://school-a.example.com/api/me -Host: school-a.example.com -Authorization: Bearer +租户后台与平台后台角色分离: -Host ----查询----> Tenant A (Active) -token scope ------> tenant -token tid --------> Tenant A -session tenant ---> Tenant A +- 租户角色、权限、菜单、用户角色绑定都带租户上下文。 +- 平台角色不带租户键,不能自动读取租户业务数据。 +- 菜单只决定 UI bootstrap 展示,不作为 API 授权依据。 +- 后台 API 必须声明明确 permission;高风险写操作按策略要求 MFA 和审计。 -四者一致:继续授权 -``` +DataScope: -如果同一请求携带 Tenant B token: +- `All`:当前租户内该模块全部资源。 +- `Restricted`:按 region/class/owner 等资源关系过滤。 +- `Self`:只允许当前用户关联资源。 +- 无法可靠表达资源关系的模块只允许 `All`,不能退化为仅按 tenant 查询。 -```text -Host -------------> Tenant A -token tid --------> Tenant B - X 不一致 -结果 -------------> 403 tenant_context_conflict -``` +## 短信验证码 -在租户自定义 Host 上,`x-tenant-code`、query `tenantCode` 或 body 中的 tenant ID 都不能切换到另一个租户。 +- 验证码生成、哈希、频控、过期和校验由自有业务服务负责。 +- `ISmsProvider` 只负责发送。 +- 发送失败必须记录失败状态,不能留下可验证验证码。 +- 登录、绑定、找回密码等场景使用独立 purpose 和频控键。 -### 4.3 Platform Host 流程演示 +## 审计与错误 -平台管理员登录: +必须落审计: -```text -POST https://admin.example.com/api/auth/login/password -{ - "realm": "platform", - "identifier": "admin@example.com", - "password": "..." -} +- 登录、刷新重放、logout-all、强制改密、MFA 变更; +- 角色、权限、成员状态、租户状态、Provider 配置、支付运营动作; +- System Scope 和跨租户平台操作。 -Host 在 PlatformHosts ----是----> tenant context 必须为空 -tenantCode ----------不得提供 -有效平台角色权限 ----必须存在 -后台权限 ------------要求 TOTP/强改密流程 -``` +错误响应: -platform token 只能在 Platform Host 使用: +- 401:未认证或 token/session 无效。 +- 403:已认证但 realm、tenant、permission、DataScope、MFA 或套餐能力不满足。 +- 404:未知 Host、不可见资源或需要隐藏存在性的资源。 +- 响应不得泄露完整手机号、openId、邮箱、密钥、支付账号或内部 provider payload。 -```text -platform token + admin.example.com -> 允许继续 -platform token + school-a.example.com -> 403 -platform token + unknown.example.com -> 拒绝 -``` +## 生产配置清单 -### 4.4 在 Platform Host 访问 tenant realm +- 正式 `PlatformHosts`。 +- 可信代理地址和网络 ACL。 +- 非通配 `AllowedHosts`。 +- JWT issuer、audience、当前 `KeyId`、RSA 私钥和旧公钥集合。 +- Data Protection 证书。 +- CORS 明确 Origin。 +- Secret encryption key。 +- 短信、对象存储、支付、通知和 AI provider 只通过租户 Provider 配置读取密钥。 -统一平台 Host 上的部分公共/认证入口允许使用 `x-tenant-code` 或 query `tenantCode` 解析租户。允许的路径前缀由 `TenantCodePathPrefixes` 控制,默认包括 auth、tenant、catalog、assets、scoreline、referral 和支付通知等入口。 - -示例: - -```text -POST http://localhost/api/auth/login/password -x-tenant-code: school-a -{ - "realm": "tenant", - "tenantCode": "school-a", - "identifier": "13800000000", - "password": "..." -} - -Platform Host + 白名单路径 + tenantCode - | - v -解析 Active Tenant A - | - v -后续 token tid / Session tenant / request tenant 必须都是 Tenant A -``` - -普通业务路径不能借 `x-tenant-code` 任意切换租户。 - -### 4.5 Forwarded Host 与可信代理 - -API 可以读取标准 Forwarded Headers,并限制 `ForwardLimit=1`。`TrustedProxyAddresses` 非空时只信任其中配置的代理;按照 ASP.NET Core Forwarded Headers 的语义,KnownProxies/KnownNetworks 同时为空会接受任意转发源,因此生产环境必须配置至少一个可信代理地址,且不能把 API 暴露为可绕过网关的公网入口。生产网关必须: - -- 覆盖客户端传入的 `X-Forwarded-Host`、`X-Forwarded-For`、`X-Forwarded-Proto`; -- 只向 API 转发一个经过验证的外部 Host; -- 把网关地址加入 `TrustedProxyAddresses`; -- 禁止 API 直接暴露到可绕过网关的公网入口。 - -没有可信代理配置时,不应假设任意客户端提供的 `X-Forwarded-Host` 会被系统信任。 - -## 5. 登录状态、强制改密与 MFA - -所有登录方式统一返回以下四种状态之一: - -- `authenticated` -- `mfa_required` -- `mfa_enrollment_required` -- `password_change_required` - -拥有任一 tenant/platform 后台权限的账号必须完成 TOTP。登录不会在 MFA 前签发业务 token,只返回 5 分钟、一次性 challenge。 - -```text -账号密码/短信/微信验证成功 - | - +--> ForcePasswordChange ------> password_change_required - | - +--> 有后台权限 + 未配置 TOTP -> mfa_enrollment_required - | - +--> 有后台权限 + 已配置 TOTP -> mfa_required - | - +--> 无后台权限 --------------> authenticated -``` - -TOTP 接口: - -- `POST /api/auth/mfa/totp/setup` -- `POST /api/auth/mfa/totp/confirm` -- `POST /api/auth/mfa/totp/verify` - -恢复码仅在首次确认 TOTP 时返回一次;每个恢复码只能兑换一次,重放会失败并记录审计。平台 challenge 的 setup/confirm/verify 也必须继续使用 Platform Host。 - -## 6. tenant/platform RBAC - -授权由 `ICurrentAccessContext` 从数据库解析: - -```text -当前 User - +-- tenant realm --> Active Membership - | +-- TenantBackendUserRole - | +-- Active TenantBackendRole - | +-- RolePermission - | +-- tenant:* permission - | - +-- platform realm -> PlatformBackendUserRole - +-- Active PlatformBackendRole - +-- RolePermission - +-- platform:* permission -``` - -后台授权不读取 JWT role claim、`User.PrimaryRole` 或 `TenantMembership.Role`。`TenantMembership` 只表达租户成员状态和业务身份。Tenant Owner 会绑定不可删除的 `tenant_owner` 系统后台角色;平台超级管理员只由 `platform_super_admin` 系统角色绑定产生。 - -当前基础权限点: - -```text -tenant:dashboard:view platform:dashboard:view -tenant:staff:manage platform:tenant:manage -tenant:role:manage platform:staff:manage -tenant:student:manage platform:role:manage -tenant:content:manage platform:question-bank:manage -tenant:settings:manage platform:audit:view -tenant:provider:manage -tenant:commerce:operate -tenant:crm:manage -tenant:commission:manage -tenant:job:manage -``` - -主要 Authorization Requirement: - -- `CurrentTenantMemberRequirement` -- `TenantPermissionRequirement(code)` -- `PlatformPermissionRequirement(code)` -- `MfaRequirement` -- `TenantResourceAccessRequirement` - -全局 fallback policy 要求认证;后台权限 policy 同时要求数据库 permission 和 MFA。拒绝访问会写统一审计。 - -### 菜单不是授权 - -tenant/platform UI bootstrap 只返回当前数据库有效权限对应的 active menu: - -```text -数据库有效 permissions ---> 过滤 active menus ---> 前端显示 - | - +-------------------------------> API Authorization Handler 再验证 -``` - -隐藏菜单不能代替 API 授权;手工调用 URL 仍会经过 policy。 - -## 7. DataScope - -角色 DataScope 合并规则: - -- `All`:允许访问当前租户内该模块全部资源,优先级最高。 -- `Restricted(regionIds, classIds)`:多个角色的 region/class 取并集,可选包含 Self。 -- `Self`:只允许 owner/当前用户关联资源。 - -资源列表、详情和写操作必须使用同一范围。越权详情或写入统一按未找到处理,返回 404,避免泄露资源是否存在。 - -```text -多个有效角色 - | - +--> 任一 All --------------------> All - | - +--> Restricted A + Restricted B -> region/class 并集 - | - +--> 只有 Self -------------------> Self -``` - -当前学生、班级、现代内容管理,以及具备 owner/region 关系的订单、支付、退款和 DirectContent 资源已在 SQL 中应用范围。支付配置、激活码、积分、优惠券、对账、CRM webhook/config 等缺少可靠 owner/region/class 外键的资源只允许 `All`,Restricted/Self 账号会 fail-closed,不能退化为仅按 tenant 查询。 - -## 8. 短信验证码安全 - -短信发送入口为 `POST /api/auth/sms/send`,仅支持 tenant realm,响应 `202` 且不返回验证码。 - -安全策略: - -- 使用 `RandomNumberGenerator.GetInt32` 生成 6 位验证码。 -- 使用服务端 pepper 的 HMAC-SHA256 保存验证码摘要。 -- 同一验证码最多失败 5 次,第 5 次原子标记 `Blocked`。 -- 正确验证码只能原子消费一次;并发请求只有一个成功。 -- 持久化限制 tenant、phone、IP、device 四个维度。 -- HTTP 命名限流再按 phone + IP 分区。 -- pepper 至少 32 个字符,缺失时启动校验失败。 - -默认额度: - -| 维度 | 默认值 | -| --- | --- | -| tenant | 100 次/小时 | -| phone | 5 次/小时 | -| IP | 20 次/小时 | -| device | 10 次/小时 | -| 验证失败 | 5 次后 Blocked | - -## 9. 审计与错误响应 - -统一审计覆盖: - -- 登录成功、失败、锁定; -- MFA enrollment、验证、恢复码; -- Session family 撤销、logout-all、refresh 重放; -- 角色、权限、菜单、用户角色绑定; -- 平台管理员 bootstrap; -- 已认证用户的授权拒绝。 - -API 使用 ProblemDetails。常见结果: - -| 状态 | 场景 | -| --- | --- | -| 400 | realm/tenantCode/Host 契约错误、无效输入 | -| 401 | 未认证、token/Session 无效 | -| 403 | 已认证但权限不足,或 Host 与 token tenant 冲突 | -| 404 | 未知租户 Host、资源不存在或 DataScope 越权 | -| 429 | 密码、短信、MFA 或全局限流 | - -## 10. 生产配置清单 - -上线前至少确认: - -1. `Tenancy:Resolution:PlatformHosts` 只包含正式平台域名。 -2. `Tenancy:Resolution:TrustedProxyAddresses` 只包含实际网关地址。 -3. JWT `Issuer`、`Audience`、当前 `KeyId`、RSA 私钥和旧公钥集合已配置。 -4. Access token 仍固定为 15 分钟,不能关闭数据库 Session 校验。 -5. `TIKU_SMS_CODE_PEPPER` 使用独立高熵值,不使用开发默认值。 -6. `TIKU_DATA_PROTECTION_CERTIFICATE_PATH` 指向包含私钥的 PKCS#12 文件,并配置密码。 -7. CORS 只允许明确 Origin;浏览器 cookie/BFF 不在当前 token JSON 契约内。 -8. API/Worker 不自动迁移数据库;部署流程显式运行 `Tiku.DbMigrator`。 -9. 首次部署使用一次性 `--bootstrap-platform-admin`,完成强制改密和 TOTP 后销毁临时密码。 -10. 网关阻断未知 Host,并禁止绕过可信代理直连 API。 - -## 11. 新接口安全检查 - -新增或修改接口时必须回答: - -- 它属于 tenant 还是 platform realm? -- 是否显式 `[AllowAnonymous]`;若不是,使用哪个 permission policy? -- Host、tenant context、JWT `tid` 和 Session 是否能形成一致闭环? -- 是否需要 MFA?后台 permission policy 默认需要。 -- 资源如何映射 owner、region、class?列表和写入是否使用相同 SQL 范围? -- 越权是否返回 404? -- 是否写审计? -- 是否需要 account+IP、phone+IP 或持久化多维限流? -- 是否错误地读取 JWT role、PrimaryRole、TenantRole 或前端菜单做授权? - -如果资源没有可靠的数据范围关联,先限制为 `All`,再通过明确的 schema 变更补足 owner/region/class 外键;禁止用字符串 ID 或 JSON 内容猜测权限范围。 +待补强事项见 [认证与授权待补强清单](authentication-authorization-hardening-plan.md)。 diff --git a/docs/migration-roadmap.md b/docs/migration-roadmap.md index 3d0b70f..70da159 100644 --- a/docs/migration-roadmap.md +++ b/docs/migration-roadmap.md @@ -1,199 +1,101 @@ -# 旧后端迁移路线 +# 迁移路线与剩余范围 -本文档用于记录从旧 PocketBase / Supabase / NestJS 后端迁移到新 ASP.NET Core + PostgreSQL 后端的阶段性状态和后续优先级。 +本文档是旧 PocketBase / Supabase / NestJS 后端迁移到 ASP.NET Core + PostgreSQL 后端的状态入口。 -仓库转正决策和第一阶段基线分别见: +## 固定原则 -- [`docs/adr/0001-authoritative-dotnet-backend.md`](adr/0001-authoritative-dotnet-backend.md) -- [`docs/migration/phase-1-repository-baseline.md`](migration/phase-1-repository-baseline.md) -- [`docs/migration/phase-2-engineering-foundation.md`](migration/phase-2-engineering-foundation.md) -- [`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) -- [`docs/migration/phase-8-ai-foundation.md`](migration/phase-8-ai-foundation.md) +- 新前端按新 REST API 对接;旧 URL 默认不兼容。 +- 旧 NestJS 只作为行为清单和验收参考。 +- 不保留 Supabase 运行时依赖、Auth/Storage provider 或 RLS 模型。 +- 数据一致性优先落 PostgreSQL FK / unique / check / index;跨表租户不变量用集中 PostgreSQL guard。 +- 外部身份、短信、对象存储、支付、通知和 AI 都通过 Application 接口与 `TenantExternalProvider` 配置解耦。 +- 新功能按业务闭环验收,不按 endpoint 数量验收。 -迁移原则: +## 已完成主线 -- 不追求旧接口逐字兼容,新前端按新 REST API 对接。 -- 不照搬 Supabase Auth/RLS,权限在 ASP.NET Authentication / Authorization 和应用服务里收口。 -- 不保留 Supabase 运行时依赖、Storage provider 或 Auth 兼容层。 -- 数据一致性落 PostgreSQL FK / unique / check / index 约束。 -- 多租户数据默认带 `TenantId`,跨租户引用优先使用 composite FK。 -- ORM 不能自动表达的跨表租户不变量,使用集中 PostgreSQL trigger / constraint trigger SQL helper,由 EF Core migration 调用并用真实 PostgreSQL 集成测试验证。 -- 外部身份、短信、对象存储、支付和通知都通过 Application 接口与 `TenantExternalProvider` 配置解耦。 -- JSON 字段使用 C# `JsonElement` + PostgreSQL `jsonb`,不落字符串。 -- 旧版明显是占位、临时脚本或平台自动化的部分,不直接硬搬,先重新设计边界。 +- 仓库转正:本仓库是唯一目标后端,见 [ADR 0001](adr/0001-authoritative-dotnet-backend.md)。 +- 工程底座:ASP.NET Core、EF Core、Npgsql、PostgreSQL、Serilog、RateLimiter、Options 校验。 +- 数据库:greenfield `InitialSchema`,真实 PostgreSQL 迁移和集成测试。 +- 租户隔离:Host 解析、`ITenantContext`、EF Query Filter、SaveChanges 拦截器、组合外键、PostgreSQL guard。 +- 共享题库:平台公共题库、租户私题、`TenantQuestionReference`、版本锁定练习。 +- 前端运行时:自定义域名、DNS/TLS 生命周期、`GET /api/runtime/bootstrap`。 +- 外部服务解耦:`TenantExternalProvider` + `TenantSecret`,身份、短信、OSS、支付、通知 provider 边界。 +- 后台底座:平台/租户 RBAC、菜单、审计、平台后台、租户后台、交易运营、Worker 任务模型。 +- 学生体验:视频搜索/播放/进度、题目解析视频、签到、积分流水、内容导入异步化。 +- AI 基础:已引入 SK 包到 Infrastructure,已固定 provider-neutral 对话存储方向;业务功能仍待实现。 -## 当前已完成 +阶段归档: -### 基础设施 +- [第一阶段:仓库转正基线](migration/phase-1-repository-baseline.md) +- [第二阶段:.NET 工程底座](migration/phase-2-engineering-foundation.md) +- [第三阶段:强租户隔离、共享题库与租户前端运行时](migration/phase-3-tenant-isolation-and-shared-question-bank.md) +- [第四阶段:外部服务解耦](migration/phase-4-external-provider-decoupling.md) +- [第五阶段:后台能力与 Worker 基座](migration/phase-5-backoffice-worker-operations.md) +- [第七阶段:学生端体验与内容消费闭环](migration/phase-7-student-experience-and-content-consumption.md) +- [第八阶段:AI 底座与教师端对话](migration/phase-8-ai-foundation.md) +- [API 契约基线](migration/contracts/README.md) -- ASP.NET Core Controller API。 -- EF Core + Npgsql + PostgreSQL。 -- 单例 `NpgsqlDataSource` + scoped `AddDbContext`。 -- `ITenantContext`、全局 Query Filter、写入拦截器和 PostgreSQL 组合约束。 -- Serilog 结构化日志。 -- CORS / RateLimiter / Options 校验。 -- Scalar / OpenAPI 基础入口。 -- 统一租户外部服务配置:`TenantExternalProvider` + `TenantSecret`。 -- 身份、短信、阿里云 OSS、支付和通知 Provider 抽象,业务层不感知 Supabase Storage、OSS bucket、微信/支付/短信 SDK 或密钥读取细节。 -- Senparc 微信登录、小程序码和微信支付 V3 边界。 -- 后台权限、菜单、平台/租户角色与操作审计底座。 -- `Microsoft.Extensions.Hosting` Worker 与统一后台任务模型。 +## 剩余范围 -### 数据库 +### 1. AI 教师端对话与反馈审核 -已按无正式业务数据前提压缩为唯一 greenfield `InitialSchema`。当前数据库模型已经覆盖: +- 租户教师后台基础对话。 +- function calling 只允许调用受审计的后端业务函数。 +- AI 审核题目反馈只生成建议、风险等级和人工复核标记,不直接改业务状态。 +- 租户 AI Provider 配置、API Key 托管、调用审计和成本记录。 +- 后续再做推荐报告、导出、RAG 和多模型路由。 -- 租户、用户、成员、认证、Session、短信验证码。 -- 平台公共题库、租户私有题库、受控题目引用、题目版本、分类主干、题集和练习蓝图。 -- 词汇、手册、分数线动态字段与记录。 -- 资源、图片、App 资源、视频解析、导入任务。 -- 学习记录、答题、收藏、错题、报告、统计。 -- 商品、订单、支付、权益、兑换码、优惠券。 -- 积分任务、积分兑换。 -- 推广、邀请码、归因、CRM 队列、佣金结算。 -- 运营内容、通知、徽章、审计。 -- 平台账单、催缴、审计告警、对账/退款相关模型。 -- 后台权限、菜单、角色、用户角色绑定和后台任务模型。 -- PocketBase 导入审计。 -- 自定义域名 DNS/TLS 生命周期和租户前端运行时配置。 +### 2. 内容导出与导入增强 -### 已迁移业务闭环 - -- 安全底座、JWT、数据库 Session、当前用户和当前租户。 -- 手机号密码登录、短信登录、微信网页登录、微信小程序登录。 -- 公开 catalog:Banner、FAQ、公告、考试日期、商品、SVIP 套餐等。 -- 内容导航、题库、词汇、手册、视频、资源公开查询。 -- 资源上传签名、确认、下载/预览签名。 -- 学生端视频搜索、播放授权、观看进度、题目解析视频查询。 -- 学生个人中心、签到、积分流水、通知、徽章、反馈。 -- 学习统计、排行榜、错题/单词复习。 -- 题目管理、内容导入预览、同步导入、异步导入任务、导入详情、词汇/手册/分数线/视频后台管理。 -- 商品下单、微信/支付宝/manual 支付、支付回调、权益发放。 -- 积分任务、积分领取、积分兑换。 -- 优惠券领取、校验、下单抵扣、零元订单发权益。 -- 租户后台商品、订单、支付、兑换码、积分、优惠券基础运营。 -- 推广邀请码、归因、埋点、二维码生成 provider。 -- 推荐管理、CRM 配置/队列/死信处理。 -- 佣金配置、来源查询、结算生成、状态流转、凭证、导出。 - -## 剩余迁移范围 - -剩余部分不建议再按“旧接口数量”机械推进。第六阶段已经把平台后台、租户后台、交易运营和 Worker 最小闭环打通;第七阶段补齐了学生端视频、签到积分流水和内容导入异步化。后续应按“能上线运营”和“体验增强”拆分。 - -### 1. AI 底座与教师端对话,独立阶段 - -旧版相关模块: - -- `ai.module.ts` - -目标能力: - -- 租户教师后台基础 AI 对话。 -- 对话 function calling 能力预留,但只允许调用受审计的后端业务函数。 -- AI 审核题目反馈,输出审核建议和风险等级,不直接改业务状态。 -- 学校推荐报告列表。 -- 推荐详情。 -- 推荐报告导出。 -- 推荐生成任务。 -- 租户级 AI Provider 配置、API Key 密钥托管和调用审计。 - -阶段约定: - -- 默认基于 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 或推荐算法,后续结合分数线动态字段、用户画像、目标院校和志愿规则重新设计。 - -### 2. 内容导出和导入处理器增强 - -当前已经支持导入预览、同步小批量导入、异步 `content_import` job 和导入详情查询。后续增强: - -- 内容导出任务查询和创建。 +- 内容导出任务创建、查询和下载。 - 题库、题目、学生数据导出到对象存储。 - 导入 preview/result/issue 更细化。 -- 大文件导入解析进度、失败行回放和重试。 -- 导入、导出输出资产统一走对象存储 provider,不暴露 bucket/key。 +- 大文件导入进度、失败行回放和重试。 -### 3. Worker 处理器补强 - -当前 Worker 已有统一任务模型、租户 scope 和部分实处理器。后续增强: +### 3. Worker 实处理器补强 - `content_export` 完整导出。 - `asset_security_scan` 接真实扫描 provider。 - `statistics_aggregation` 增量聚合。 - `commerce_reconciliation` 接真实 provider bill downloader。 -- `tenant_domain_recheck` 增加周期调度和告警联动。 +- `tenant_domain_recheck` 周期调度和告警联动。 ### 4. 后台运营细化 -平台后台、租户后台和交易运营已有主线能力,后续按运营优先级补: - - 租户 secrets 通用后台管理。 -- 租户监督规则和跟进报表细化。 -- 租户洞察报表。 +- 租户监督规则、跟进报表和洞察报表。 - 更细粒度 RBAC 权限点。 -- 管理后台操作审计覆盖率补齐。 +- 操作审计覆盖率补齐。 - 发票、催缴、佣金联动调账。 ### 5. 旧路径兼容评估 -默认不迁旧 URL,只迁行为能力。只有前端明确依赖且重写成本高时,才增加薄兼容 Controller;兼容层不得恢复旧 Supabase、旧 grant/adoption 公共题库授权模型、旧 `QuestionIds` JSON 或旧 Provider 配置表。 +只有前端明确依赖且重写成本高时,才增加薄兼容 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 相关任何路径。 +已由新接口替代的旧行为: -## 推荐后续顺序 +- `/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` -```text -1. AI 底座独立阶段:Semantic Kernel + 租户自带 API Key + 教师端对话 -2. 内容导出 + 导入处理器增强 -3. Worker 统计、扫描、对账、域名复验处理器补强 -4. 后台运营细化:secrets、监督、洞察、审计、发票/催缴 -5. 按前端实际依赖做旧路径兼容评估 -``` - -## 每批固定验收 - -每批迁移完成后都必须: +## 每批验收 ```bash dotnet restore TIKU-BACKEND.slnx dotnet build TIKU-BACKEND.slnx --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 +git diff --check +git status --short --branch ``` - -如果某批不涉及数据库 migration,也仍然运行 migration script,确认当前模型快照和迁移链没有损坏。 - -## 下一批建议 - -下一批建议执行独立 AI 阶段,但先只做架构底座和一个最小推荐报告闭环,不直接接生产模型: - -- 增加 `TenantExternalProviderCapability.Ai` 和密钥配置校验。 -- 增加 Application AI 抽象,不让 Controller 直接接触 SK。 -- Infrastructure 引入 Semantic Kernel provider,实现 fake/local stub 和真实 provider 边界。 -- 设计推荐报告数据模型、任务模型、调用审计和成本记录。 -- 完成报告生成任务、列表、详情和导出骨架。 - -建议暂缓: - -- 复杂 RAG。 -- 自动志愿填报决策。 -- 多模型路由优化。 -- 生产 API Key 托管 UI 之外的手工配置方案。 diff --git a/docs/migration/contracts/README.md b/docs/migration/contracts/README.md index 84334a4..edab195 100644 --- a/docs/migration/contracts/README.md +++ b/docs/migration/contracts/README.md @@ -2,11 +2,11 @@ `operation-inventory.csv` 是旧 NestJS 与当前 .NET 运行时 OpenAPI 的机械比较结果。 -状态说明: +状态: -- `exact_match`:HTTP 方法和路径完全一致。仍需核对 DTO、响应、权限和业务错误。 -- `legacy_only`:只存在于旧 NestJS;后续决定迁移、替代或删除。 -- `target_only`:只存在于 .NET;通常是新 REST 设计、诊断接口或路径调整。 +- `exact_match`:HTTP 方法和路径完全一致,仍需核对 DTO、响应、权限和业务错误。 +- `legacy_only`:只存在于旧 NestJS,后续决定迁移、替代或删除。 +- `target_only`:只存在于 .NET,通常是新 REST 设计、诊断接口或路径调整。 重新生成: @@ -17,4 +17,4 @@ python3 scripts/compare_openapi.py \ --output docs/migration/contracts/operation-inventory.csv ``` -旧 NestJS 开发环境文档默认是 `/openapi.json`,当前 .NET 开发环境文档是 `/openapi/v1.json`。原始 OpenAPI 文件可能较大且会频繁变化,不提交仓库;提交归一化后的 CSV 基线。 +原始 OpenAPI 文件较大且变化频繁,不提交仓库;只提交归一化后的 CSV 基线。 diff --git a/docs/migration/phase-1-repository-baseline.md b/docs/migration/phase-1-repository-baseline.md index a5c9da6..793529e 100644 --- a/docs/migration/phase-1-repository-baseline.md +++ b/docs/migration/phase-1-repository-baseline.md @@ -1,75 +1,35 @@ # 第一阶段:仓库转正基线 -基线日期:2026-07-27。 +状态:已完成。 -## 仓库身份 +## 目标 -- 本仓库是 ASP.NET Core 目标后端,后续新功能只在这里开发。 -- 旧 NestJS 后端只用于核对业务行为、接口契约和数据迁移。 -- 保留现有 Git 历史,不重新初始化仓库。 -- 正式远程使用 `https://git.gongxue100.com/xiongyuxing/tiku-backend.net.git`。 -- 默认分支统一为 `main`,远程创建后再设置保护规则。 +- 确认 ASP.NET Core + EF Core + PostgreSQL 仓库为唯一目标后端。 +- 建立旧 NestJS OpenAPI 与当前 .NET OpenAPI 的机械比较基线。 +- 接入自建 Git 上游。 -## 当前可验证基线 +## 结果 -| 项目 | 结果 | +- 旧 NestJS 仅作为行为、接口和迁移参考。 +- 新功能在 .NET 仓库开发。 +- 旧 URL 不要求逐字兼容。 +- API 差距记录在 `docs/migration/contracts/operation-inventory.csv`。 + +基线快照: + +| 项 | 数量 | | --- | ---: | -| 旧 NestJS OpenAPI 路径 | 283 | | 旧 NestJS OpenAPI 操作 | 342 | -| 当前 .NET OpenAPI 路径 | 192 | | 当前 .NET OpenAPI 操作 | 237 | -| 方法和路径完全一致 | 165 | | 仅旧 NestJS 存在 | 177 | | 仅当前 .NET 存在 | 72 | -| 当前 .NET Controller 文件 | 20 | -| 当前 EF Core Migration | 7 | -OpenAPI 数字来自开发环境运行时文档,不以 Controller 特性数量或 README 手工统计为准。详细清单见 [operation-inventory.csv](contracts/operation-inventory.csv)。 - -## 权威来源 - -| 内容 | 权威来源 | -| --- | --- | -| 目标架构 | `docs/adr/0001-authoritative-dotnet-backend.md` | -| API 契约 | 运行时 `/openapi/v1.json` | -| 数据库模型 | EF Core 实体和 Fluent Configuration | -| 数据库结构历史 | `Tiku.Infrastructure/Persistence/Migrations/` | -| 生产迁移入口 | `Tiku.DbMigrator` | -| 迁移范围与取舍 | `docs/migration/contracts/operation-inventory.csv` 及后续决策记录 | - -## 自建 Git 接入 - -远程仓库创建完成后,在仓库根目录执行: +## 验收 ```bash -git remote add origin https://git.gongxue100.com/xiongyuxing/tiku-backend.net.git -git push -u origin main +dotnet build TIKU-BACKEND.slnx --no-restore +dotnet test Tiku.UnitTests/Tiku.UnitTests.csproj --no-restore +dotnet test Tiku.IntegrationTests/Tiku.IntegrationTests.csproj --no-restore +dotnet ef migrations script --project Tiku.Infrastructure --startup-project Tiku.DbMigrator +git diff --check ``` - -如果服务端已经自动创建了 README 或初始提交,不要强制推送;先拉取并确认如何合并历史。 - -## 基线验证 - -2026-07-27 在 macOS、.NET SDK 10.0.301 上完成: - -```text -dotnet restore:通过 -dotnet build:通过,0 warning / 0 error -dotnet test:通过,261/261 -dotnet format --verify-no-changes:通过 -EF Core migration script:通过,生成 4166 行 SQL -API 契约清单校验:通过,414 个唯一操作 -``` - -首次 restore 曾因 `api.nuget.org` TLS EOF 和下载超时失败;串行重试成功。这是依赖源网络故障,不是源码或项目路径问题。 - -## 第一阶段退出条件 - -- [x] 明确 .NET 是唯一目标后端。 -- [x] 保留并接续现有 Git 历史。 -- [x] 仓库命令不依赖开发者机器的绝对路径。 -- [x] 建立旧 NestJS 与当前 .NET 的 OpenAPI 操作基线。 -- [x] 固定 EF Core Migration 的权威地位。 -- [x] 配置自建 Git remote。 -- [x] 首次推送 `main`。 -- [ ] 在自建 Git 上启用 `main` 分支保护和 CI(等待远程仓库)。 diff --git a/docs/migration/phase-2-engineering-foundation.md b/docs/migration/phase-2-engineering-foundation.md index 3725295..1d334e2 100644 --- a/docs/migration/phase-2-engineering-foundation.md +++ b/docs/migration/phase-2-engineering-foundation.md @@ -1,87 +1,27 @@ # 第二阶段:.NET 工程底座 -完成日期:2026-07-27。 +状态:已完成。 -本阶段按 greenfield 项目建设,不承担旧 Supabase / NestJS 数据兼容。数据库结构以 EF Core 实体、Fluent Configuration 和 Migration 为唯一权威来源;API 运行时不自动修改数据库。 +## 目标 -## 数据库开发与发布边界 +- 建立 ASP.NET Core / EF Core / PostgreSQL 工程底座。 +- 固定数据库迁移边界:API 不自动改库,迁移由 `Tiku.DbMigrator` 执行。 +- 使用真实 PostgreSQL 验证 schema、事务、JSONB、约束和扩展。 -- 开发采用 code first:修改实体或 Fluent Configuration 后生成 EF Core Migration。 -- Migration 统一保存在 `Tiku.Infrastructure/Persistence/Migrations/`。 -- `Tiku.DbMigrator` 是执行 Migration 的独立入口。 -- `Tiku.Api` 和 `Tiku.Worker` 不调用 `Database.Migrate()`,避免多实例启动时争抢 DDL,也避免应用进程持有结构变更权限。 -- 发布前生成并审查 SQL;生产环境由部署流程显式执行 DbMigrator 或审核后的 SQL。 -- Development 未配置连接串时默认连接本机 `tiku` 数据库,并使用当前系统用户名,不内置密码。 -- 非 Development 环境必须显式配置 `ConnectionStrings:Database` 或 `DATABASE_URL`,缺失时启动失败。 +## 结果 -常用命令: +- Development 未配置连接串时默认连接本机 `tiku` 数据库并使用当前系统用户。 +- EF Core 使用 Npgsql 与 PostgreSQL 扩展。 +- Secret payload 进入加密字段;不提交本地连接串和密钥。 +- 真实 PostgreSQL 集成测试成为数据库能力验收入口。 + +## 验收 ```bash -dotnet ef migrations add \ - --project Tiku.Infrastructure \ - --startup-project Tiku.DbMigrator - -dotnet ef migrations script \ - --project Tiku.Infrastructure \ - --startup-project Tiku.DbMigrator - +dotnet build TIKU-BACKEND.slnx --no-restore +dotnet test Tiku.UnitTests/Tiku.UnitTests.csproj --no-restore +dotnet test Tiku.IntegrationTests/Tiku.IntegrationTests.csproj --no-restore +dotnet ef migrations script --project Tiku.Infrastructure --startup-project Tiku.DbMigrator dotnet run --project Tiku.DbMigrator +git diff --check ``` - -## 真实 PostgreSQL 测试基线 - -`Tiku.IntegrationTests` 已移除 EF Core InMemory provider,全部集成测试连接真实 PostgreSQL: - -- 管理连接可通过 `TIKU_TEST_POSTGRES_ADMIN` 覆盖。 -- 默认管理连接为 `Host=localhost;Database=postgres;Username=<当前系统用户>`。 -- 测试进程创建一次已执行完整 Migration 的模板数据库。 -- 每个 `ApiTestFactory` 从模板克隆独立 `tiku_it_*` 数据库,测试之间不共享业务数据。 -- Factory 释放时终止连接并删除克隆库;进程退出时删除模板库。 -- 测试只允许管理 `tiku_it_*` 命名空间,不访问或清理已有 `tiku` 业务数据库。 - -真实关系型测试暴露并修正了以下 InMemory 无法可靠验证的问题: - -- 测试夹具缺失的 Region、School、Subject、Category、Question 等外键实体。 -- `Question` 与 `QuestionVersion` 当前版本引用形成的插入循环,改为事务内分阶段保存。 -- `ReferralLead.FirstTrackId` 与 `ReferralTrack.LeadId` 形成的插入循环,改为事务内分阶段保存。 -- PostgreSQL JSON 映射不能将未定义的 `default(JsonElement)` 生成为 SQL literal,缺省数组改为有效的 `[]`。 -- 徽章发放不再伪造超长 legacy ID;通知使用新记录 ID 生成独立、稳定的去重键。 - -## 配置与密钥安全 - -- 根配置不再提交可用于生产的 JWT signing key。 -- Development 只使用明确标识的开发 JWT key;Production 拒绝该 key。 -- 租户第三方凭据使用 AES-256-GCM envelope 保存。 -- 每条密文使用 12-byte nonce、16-byte authentication tag。 -- AAD 绑定 `tenantId`、`secretRef` 和 `keyId`,密文不能跨租户或跨引用替换。 -- 主密钥必须是 Base64 编码的 32 字节值;Production 拒绝仓库内的开发测试 key。 -- 运行时配置键为 `Security:TenantSecrets:KeyId` 和 `Security:TenantSecrets:MasterKey`;环境变量可使用 `TIKU_TENANT_SECRET_KEY_ID` 与 `TIKU_TENANT_SECRET_MASTER_KEY`。 -- 新 schema 只保留 `encryption_key_id`、`encrypted_payload`、`encryption_nonce`、`encryption_tag`,不保留明文字段或明文回退读取路径。 - -本阶段的 `EncryptTenantSecretPayloads` Migration 会直接删除旧 `secret_payload` 字段。这是 greenfield 决策,不提供旧数据转换或兼容窗口。 - -## 验证结果 - -2026-07-27 在本机 PostgreSQL 18.4、.NET SDK 10.0.301 上完成: - -```text -dotnet build:通过,0 warning / 0 error -dotnet test:通过,265/265 - UnitTests:16/16 - IntegrationTests(真实 PostgreSQL):249/249 -dotnet format --verify-no-changes:通过 -git diff --check:通过 -EF Core migration script:通过,生成 4184 行 SQL -测试数据库清理:通过,无 tiku_it_* 残留 -``` - -## 第二阶段退出条件 - -- [x] EF Core Migration 成为 code-first schema 的唯一版本历史。 -- [x] API 与 Worker 不在启动时自动执行 Migration。 -- [x] 生产数据库、JWT 和租户密钥配置缺失时 fail fast。 -- [x] 租户第三方凭据只保存 AES-GCM 密文 envelope。 -- [x] 集成测试完全切换到隔离的真实 PostgreSQL 数据库。 -- [x] 修复真实数据库暴露的外键、循环依赖、JSON 和字段长度问题。 -- [x] 全量构建、测试、格式与 Migration SQL 验证通过。 - diff --git a/docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md b/docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md index 05a2623..e48a487 100644 --- a/docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md +++ b/docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md @@ -1,221 +1,52 @@ -# 第三阶段:强租户隔离、共享题库与租户前端运行时 - -状态:已实施并通过真实 PostgreSQL 验收(2026-07-27)。 - -本阶段按 greenfield 项目实施,不兼容现有错误模型,也不承担正式业务数据迁移。阶段目标不是简单给现有查询补 `TenantId`,而是同时建立请求、EF Core、写入和 PostgreSQL 四层租户边界,并让公共题库能够完整参与租户学生的组卷、答题、收藏和错题闭环。 - -原先预计的 3~5 个工作日只覆盖基础租户过滤。加入共享题库、版本锁定、域名入口和租户前端运行时后,完整阶段预计 10~14 个工作日。 - -## 已确认的产品规则 - -- 系统只存在一个 `TenantMode.PlatformOwned` 平台主体,平台主体拥有全部公共题库。 -- `Trial` 或 `Active` 且未过期的租户自动拥有全部公共题库访问权,不逐库授权。 -- 租户可以上传自己的私有题库,私有题只对本租户学生和管理员可见。 -- 公共题和租户私有题可以混合进入同一试卷、章节练习或题单。 -- 平台分类是公共主干,租户可以在公共节点下增加仅本租户可见的扩展节点。 -- 新练习使用题目当前已发布版本;已开始练习锁定原版本,历史答题永久按原版本回放。 -- `PastDue` 或 `Cancelled` 租户不能浏览公共题答案或创建新的公共题练习;未过期的既有会话可以完成,历史答题、收藏、错题和统计继续可见。 -- 租户自定义域名通过 CNAME 指向统一前端和网关,浏览器使用同域 `/api` 访问后端。 -- 第一版前端配置覆盖品牌、主题令牌、功能开关、导航和首页模块,不实现任意低代码页面或脚本注入。 -- 本阶段不引入 PostgreSQL RLS。隔离由请求上下文、EF Core Query Filter、写入拦截器、数据库约束和真实 PostgreSQL 测试共同保证。 - -## 3A:请求租户与持久化边界 - -### 请求上下文 - -使用只读 `ITenantContext` 替换可由业务代码调用 `Load` 的 `ICurrentTenant`。公开属性固定为: - -- `TenantId` -- `TenantCode` -- `ResolutionSource` -- `IsResolved` -- `IsSystem` - -只有 API 入口的内部初始化器可以设置请求租户。普通 Controller 和 Service 只能读取上下文,不能切换租户。 - -平台任务、Worker 和公共题库基础设施通过 `ITenantExecutionScope` 创建新的 DI scope。System scope 必须提供调用方和原因,记录结构化审计;不允许在现有请求 scope 内临时改写租户。 - -### EF Core 防护 - -- 生产和测试均从 `AddDbContextPool` 改为 scoped `AddDbContext`。 -- 所有租户实体实现统一 marker interface。 -- 全局 Query Filter 自动添加当前 `TenantId`;未解析租户时查询默认返回空集合。 -- 普通租户 Query Filter 永远不自动包含平台主体数据,公共内容只能通过专用服务访问。 -- `SaveChangesInterceptor` 对 Added 自动写入当前租户,并拒绝伪造租户、跨租户修改或删除以及修改已有记录的租户归属。 -- 未解析租户的普通写入直接失败;System scope 也不能把已有记录改归其他租户。 - -模型启动审计必须拒绝以下情况: - -- 实体存在 `TenantId` 却没有明确分类。 -- 租户范围内的唯一索引未包含 `TenantId`。 -- 租户实体之间的外键未包含组合租户键。 -- 新增租户实体却没有对应 Query Filter。 - -普通业务代码禁止调用 `IgnoreQueryFilters()`、`FromSql`、`ExecuteSql` 或直接创建 `NpgsqlCommand`。确有需要的实现只能位于集中基础设施边界,并由架构测试维护允许列表。 - -## 3B:域名入口与租户解析 - -租户解析必须在认证和任何租户数据库查询之前完成: - -1. 网关移除客户端提供的 `Forwarded`、`X-Forwarded-Host` 等头。 -2. 只接收受信代理重写的 Host 信息。 -3. `TenantResolutionMiddleware` 通过只读 `ITenantDirectory` 查询 Active 域名和 Active 租户。 -4. 未知、未验证、已禁用域名在认证前返回 404。 -5. JWT tenant claim 与 Host 解析结果不一致时返回 403。 - -自定义域名请求不得通过 `host`、`tenantCode` 查询参数或 `x-tenant-code` 覆盖租户。显式 tenant code 只允许平台控制域名上的登录引导接口使用,多来源冲突必须拒绝。 - -域名生命周期固定为: - -1. 创建 Pending 域名并生成验证令牌。 -2. 租户设置 CNAME 和 TXT 所有权记录。 -3. Worker 验证 DNS。 -4. 网关完成 TLS 配置。 -5. 域名变为 Active 后才参与请求解析。 - -`TenantDomain.Host` 全局唯一;每个租户最多一个 Active 主域名。解析结果使用短 TTL 缓存,域名或租户状态变化时主动失效。 - -## 3C:共享题库、租户私库与学习闭环 - -### 内容所有权 - -- 公共题库、题目和题目版本的 `TenantId` 为平台主体 ID。 -- 私有题库、题目和题目版本的 `TenantId` 为上传租户 ID。 -- 删除容易与真实所有权冲突的 `QuestionBank.SourceScope`。 -- 删除 `QuestionBankGrant` 和 `TenantQuestionBankAdoption` 作为访问控制主模型。 -- 新增 `TenantQuestionBankPreference`,只保存租户对公共题库的显示/隐藏、别名、排序和导航位置。 -- `IPublicQuestionAccessPolicy` 根据租户状态和订阅状态统一判断能否开始新的公共题练习。 - -数据库对平台主体增加唯一约束,保证只能存在一个 `PlatformOwned` 租户。 - -### 公共分类主干与租户扩展 - -分类节点具有明确的 `OwnerTenantId`。父节点引用保存 `ParentOwnerTenantId + ParentId`: - -- 平台节点只能组成公共主干。 -- 租户扩展节点可以挂到平台节点或本租户节点。 -- 不允许租户 A 的节点挂到租户 B 的节点。 -- 公共题只能关联平台分类。 -- 租户私题可以关联平台分类或本租户扩展分类。 - -所有分类、题库和题目关联使用包含所有者 ID 的组合外键,并通过 PostgreSQL 约束或集中触发器验证“平台或本租户”规则。 - -### 受控题目引用 - -新增 `TenantQuestionReference`: - -- `TenantId`:消费题目的租户。 -- `QuestionOwnerTenantId`:平台主体或当前租户。 -- `QuestionId`:实际题目。 - -三列建立唯一约束和组合外键。引用按需创建,不预生成“所有租户 × 全部公共题”。数据库只允许当前租户引用平台公共题或本租户私题,永远拒绝引用其他租户私题。 - -公开查询返回 `QuestionLocator { source, questionId }`,其中 `source` 只允许 `platform` 或 `tenant`。客户端不能提交任意 owner tenant ID;写操作由专用服务把 Locator 解析为受控题目引用。 - -### 组卷、练习和历史版本 - -- `QuestionCollectionItem` 改为引用 `TenantQuestionReference`,允许公共题和私题混合组卷。 -- 新增规范化 `PracticeSessionQuestion`,删除 `PracticeSession.QuestionIds` JSON。 -- SessionQuestion 保存题目顺序、分值、题目所有者、题目 ID 和创建会话时锁定的版本 ID。 -- 答题接口只接受 `sessionQuestionId`,不再接受裸 `QuestionId`;单题练习也创建轻量会话。 -- `AnswerRecord` 关联 SessionQuestion。 -- `FavoriteQuestion` 和 `WrongQuestion` 关联受控题目引用,不再假设学习租户和题目所有者相同。 -- 旧题目版本只能归档,不能物理删除。 - -## 3D:租户前端运行时配置 - -整合现有 Branding、Theme 和 PublicConfig,形成版本化 `TenantFrontendConfig`,配置范围包括: - -- 品牌名称、Logo、Favicon 和客服信息。 -- 颜色、字体、间距和圆角等主题令牌。 -- 学生端功能开关。 -- 导航菜单。 -- 首页模块及排序。 - -配置采用 Draft → Preview → Publish。发布使用乐观并发版本号,服务端在发布前验证结构;不接受任意 HTML、JavaScript、外部脚本或管理端功能开关进入公开配置。 - -新增 `GET /api/runtime/bootstrap`: - -- 只根据当前可信 Host 返回配置。 -- 返回 `schemaVersion`、`configVersion`、租户代码、品牌、主题、功能、导航和首页模块。 -- 不返回内部租户 ID、密钥或管理端配置。 -- 支持 ETag / 304。 -- 配置发布、域名停用或租户暂停时主动失效缓存。 - -现有 `tenant/resolve?host=` 不再作为自定义域名的公开切租户入口,只保留给受控平台登录引导流程。 - -## 3E:重建迁移与验收 - -项目尚无正式业务数据,因此不编写兼容迁移或双写逻辑: - -- 完成模型重构后删除现有 Migration 历史和 Snapshot。 -- 重新生成单一 Initial Schema。 -- 重建本地开发数据库。 -- 使用 `Tiku.DbMigrator` 从空 PostgreSQL 执行完整迁移。 -- API 和 Worker 继续禁止启动时自动执行 Migration。 - -数据库必须落地以下硬约束: - -- 唯一平台主体。 -- 域名全局唯一。 -- 租户唯一索引包含租户键。 -- 题目、版本和分类使用所有者组合键。 -- 题目引用只能指向平台或本租户。 -- SessionQuestion 的版本属于对应题目。 -- 学习记录与当前租户、学生和会话一致。 - -### EF Core 与 PostgreSQL 约束分工 - -EF Core 负责实体、Fluent Configuration、Migration 生成和迁移执行入口,迁移仍然是 code-first 管理,不允许去生产库手工补结构。 - -但以下跨表、跨租户不变量不能指望 ORM 自动推导: - -- `TenantQuestionReference` 只能引用平台主体公共题或当前租户私题,不能引用其他租户私题。 -- `TaxonomyNode` 的父节点只能属于平台主体或当前租户,不能挂到其他租户节点。 -- 需要读取 `tenants.mode` 或比较多列 owner 关系的规则。 - -原因是 PostgreSQL `CHECK` 不能跨表查询,普通 FK 只能证明目标记录存在,不能表达“目标 owner 必须是平台主体或本租户”。这类规则必须落到 PostgreSQL trigger / constraint trigger。 - -实现要求: - -- 触发器 SQL 必须集中在基础设施层,例如 `Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs`。 -- Migration 只调用集中 helper:`migrationBuilder.EnsureTenantIsolationGuards()` / `migrationBuilder.DropTenantIsolationGuards()`。 -- 重建或压缩 `InitialSchema` 后,必须在 `Up()` 末尾调用 `EnsureTenantIsolationGuards()`,在 `Down()` 开头调用 `DropTenantIsolationGuards()`。 -- 不允许把触发器 SQL 零散复制到多个 migration。 -- Migration script 测试必须断言 guard function 和 trigger 存在,避免重建基线时漏掉数据库硬防线。 -- 每个触发器必须有真实 PostgreSQL 集成测试覆盖允许路径和拒绝路径。 -- 如果将来新增类似“平台或本租户”的 owner 规则,优先补集中 SQL helper 和模型/集成测试,不要只靠 Service 手写校验。 - -## 测试矩阵 - -真实 PostgreSQL 测试创建平台主体、租户 A、租户 B 及三套内容,至少覆盖: - -- A 默认不能读取、修改、删除或 Attach 伪造 B 的租户数据。 -- A 可以访问公共题和 A 私题,不能构造 B 私题引用。 -- 公共题和私题可以混合组卷,并完成答题、收藏、错题和统计。 -- 公共题 V1 创建会话后发布 V2:旧会话仍用 V1,新会话使用 V2,历史答案可回放。 -- Trial/Active 可以创建公共题练习;PastDue/Cancelled 只能完成未过期会话并查看历史。 -- 域名 A 携带租户 B JWT 时被拒绝;伪造 Host、tenantCode 和转发头不能切租户。 -- Pending 域名、未知域名和 Suspended 租户无法获取运行时配置。 -- 配置草稿不影响线上;发布后 ETag 和缓存版本改变。 -- 新增租户实体漏写 Filter、组合外键或租户索引时模型测试失败。 -- 架构测试发现未经允许的 Query Filter 绕过或原生 SQL 时失败。 -- 从空 PostgreSQL 执行 Initial Schema、启动 API 并通过全量测试。 - -## 实施顺序和退出条件 - -实施顺序固定为: - -1. 3A:只读租户上下文、Query Filter、写入拦截器。 -2. 3B:Host 解析、域名生命周期和 JWT 租户一致性。 -3. 3C:公私内容所有权、分类扩展、题目引用和学习闭环。 -4. 3D:前端运行时配置、发布和缓存。 -5. 3E:重建 Migration、真实 PostgreSQL 隔离测试和全量验证。 - -第三阶段只有在以下条件全部满足后才能结束: - -- 新增普通租户实体即使开发者忘记手写 `TenantId` 条件也不会跨租户读取。 -- API 无法通过 DTO、Host、Header、JWT 或 Attach 伪造其他租户数据。 -- 公共题库和租户私库能够混合完成组卷、答题、收藏和错题闭环。 -- 题目发布新版本不会改变进行中会话或历史答案。 -- 租户自定义域名能够安全获得已发布的前端运行时配置。 +# 第三阶段:租户隔离、共享题库与前端运行时 + +状态:已完成主线设计和实现。 + +## 目标 + +- 普通业务代码默认只能读取和写入当前租户数据。 +- 公共题库由平台主体拥有,有效租户可访问。 +- 租户私题只属于本租户。 +- 公共题和私题可混合组卷、答题、收藏、错题和统计。 +- 租户自定义域名安全解析到统一前端运行时配置。 + +## 结果 + +- `ITenantContext` 只读化,请求租户由中间件解析。 +- EF Core Query Filter 自动按租户过滤。 +- SaveChanges 拦截器自动写入当前租户并拒绝跨租户写入。 +- DbContext 使用 scoped `AddDbContext`,避免池化串租户状态。 +- `TenantResolutionMiddleware` 在认证前按可信 Host 解析租户。 +- JWT tenant claim 与 Host 解析不一致时返回 403。 +- 未知、Pending、禁用域名在业务前返回 404。 +- 唯一 `PlatformOwned` 租户拥有公共题库、公共题、公共题版本和公共分类主干。 +- `TenantQuestionReference` 作为公共题/私题消费引用。 +- `PracticeSessionQuestion` 锁定题目版本,历史答题按原版本回放。 +- `TenantFrontendConfig` 支持 Draft / Preview / Publish。 +- `GET /api/runtime/bootstrap` 仅根据当前 Host 返回公开配置。 + +## 数据库边界 + +EF Core 负责实体、索引、外键和普通约束。以下跨表租户不变量由集中 PostgreSQL guard 管理: + +- `TenantQuestionReference` 只能引用平台公共题或当前租户私题。 +- `TaxonomyNode` 父节点只能属于平台主体或当前租户。 + +维护规则见根目录 README 的“数据库与 ORM 分工”。 + +## 验收 + +- A 租户不能读取、修改、删除或 Attach B 租户数据。 +- A 可访问公共题和 A 私题,不能构造 B 私题引用。 +- 公共题 V1 创建会话后发布 V2,旧会话仍使用 V1,新会话使用 V2。 +- Host A 携带 Tenant B JWT 返回 403。 +- Pending/未知域名和 Suspended 租户无法获取 runtime bootstrap。 +- 新增租户实体漏写 filter、组合外键或租户索引时模型测试失败。 + +```bash +dotnet build TIKU-BACKEND.slnx --no-restore +dotnet test Tiku.UnitTests/Tiku.UnitTests.csproj --no-build +dotnet test Tiku.IntegrationTests/Tiku.IntegrationTests.csproj --no-build +dotnet ef migrations script --project Tiku.Infrastructure --startup-project Tiku.DbMigrator +git diff --check +``` diff --git a/docs/migration/phase-4-external-provider-decoupling.md b/docs/migration/phase-4-external-provider-decoupling.md index f9d5396..fe96316 100644 --- a/docs/migration/phase-4-external-provider-decoupling.md +++ b/docs/migration/phase-4-external-provider-decoupling.md @@ -1,141 +1,42 @@ -# 第四阶段:外部服务解耦与租户级 Provider 模块化 +# 第四阶段:外部服务解耦 -状态:已实施并通过构建、单元测试、真实 PostgreSQL 集成测试和空库迁移验收(2026-07-27)。 +状态:已完成。 -本阶段按“完全不用 Supabase、无正式业务数据”的前提实施。不保留 Supabase Auth、Supabase Storage、旧 Provider 配置或旧 DTO 兼容层。目标是让业务层只表达身份、短信、对象存储、支付和通知的业务意图,第三方 SDK、账号、bucket、密钥和 claim 结构都收敛到 Infrastructure provider 边界。 +## 目标 -## 设计结论 +- 完全移除 Supabase Auth / Storage 兼容层。 +- 业务层只依赖身份、短信、对象存储、支付和通知抽象。 +- 第三方 SDK、账号、bucket、密钥和 claim 结构只出现在 Infrastructure provider 边界。 -- Identity 默认使用自有 JWT、数据库 Session、密码、短信验证码和微信认证。 -- Object Storage 默认使用阿里云 OSS,`local_dev` 只允许本地测试。 -- Database 固定为 EF Core + Npgsql + PostgreSQL。 -- Provider 配置统一使用 `TenantExternalProvider` + `TenantSecret`。 -- 不实现 Supabase Auth 兼容层。 -- 不实现 Supabase Storage provider。 -- 不继续扩散 `TenantAuthProvider`、`TenantPaymentAccount` 这类专用配置表。 +## 结果 -## 统一 Provider 配置 +- Provider 配置统一为 `TenantExternalProvider` + `TenantSecret`。 +- `Capability` 覆盖 Identity、ObjectStorage、Sms、Payment、Notification。 +- 同一租户内 `Capability + Provider` 唯一。 +- `ConfigPublic` 只保存公开配置;敏感字段必须进入 `TenantSecret`。 +- 删除旧 `TenantAuthProvider`、`TenantPaymentAccount` 和 Supabase storage provider 路径。 +- 阿里云 OSS、阿里云短信、微信、支付宝 SDK 只允许在 Infrastructure 使用。 -新增统一租户外部服务配置模型 `TenantExternalProvider`,核心字段包括: +## 接口边界 -- `TenantId` -- `Capability` -- `Provider` -- `Status` -- `DisplayName` -- `ConfigPublic` -- `SecretRef` -- `Priority` -- `Metadata` -- 审计字段 +- `IIdentityProvider`:封装 password、sms、wechat_web、wechat_miniapp 身份解析,不签发 JWT。 +- `ISmsProvider`:只负责发送,验证码生成、哈希、频控和校验归业务服务。 +- `IObjectStorageService`:bucket/provider 从租户配置解析,业务输入不得任意覆盖。 +- `IPaymentProvider`:支付账户和密钥从统一 Provider 配置加载。 +- `INotificationProvider`:默认站内通知持久化,后续外发通道按 provider 扩展。 -`Capability` 固定为: +## 验收 -- `Identity` -- `ObjectStorage` -- `Sms` -- `Payment` -- `Notification` +- 租户 A/B Provider 配置和密钥互不读取。 +- `ConfigPublic` 拒绝 `secret`、`token`、`key`、`privateKey` 等敏感字段。 +- 资产上传不能伪造 bucket/provider。 +- 业务层不引用第三方 SDK namespace。 +- 生产代码不回流 Supabase provider 或旧专用配置表。 -同一租户内 `Capability + Provider` 唯一。默认 Provider 选择规则是 `Status = Active` 且优先级最高;需要指定 provider code 的入口必须仍然受当前租户上下文约束。 - -`ConfigPublic` 只允许公开配置,例如: - -- identity:`appId` -- payment:`merchantId` -- object_storage:`region`、`endpoint`、`bucketAlias` -- sms / notification:`templateCode` - -密钥一律进入 `TenantSecret`,由 `SecretRef` 关联。公开配置写入路径必须拒绝 `secret`、`token`、`key`、`privateKey` 等敏感字段,避免把第三方凭据落到普通 JSON 配置里。 - -## Application 层接口边界 - -### `IIdentityProvider` - -封装 password、sms、wechat_web、wechat_miniapp 等身份解析,不直接把第三方 claim 结构散落在业务服务里,也不负责绕过统一 Session 签发流程。 - -微信登录通过当前租户的 `TenantExternalProvider(Capability = Identity)` 读取 `appId` 和 `SecretRef`,再由基础设施层完成第三方交互。`UserIdentity.Provider` 只保存标准 provider code。 - -### `ISmsProvider` - -只负责发送验证码或模板短信。验证码生成、哈希、频控、过期、校验和登录 Session 仍归自有业务服务负责。发送失败时记录失败状态,不能误判验证码可用。 - -### `IObjectStorageService` - -业务输入不接受任意 bucket。对象存储 provider、bucket alias、endpoint、region 由当前租户的 `TenantExternalProvider(Capability = ObjectStorage)` 解析。 - -资产表可以保存 `StorageProvider`、`Bucket`、`ObjectKey` 作为落库事实,但这些字段只允许由存储服务写入,不对业务 DTO 暴露成任意可写参数。 - -### `IPaymentProvider` - -支付账户配置从统一 Provider 配置加载。支付回调仍按 provider code 路由,但必须校验签名、租户、订单号和幂等事件。 - -旧 `TenantPaymentAccount` 表和旧支付专用配置模型已删除,管理侧应用模型只保留面向租户后台的 provider 视图。 - -### `INotificationProvider` - -默认实现为站内通知持久化。普通业务不直接跨模块 new `UserNotification`,后续 email、企微、短信模板等外发能力都应作为 Notification provider 扩展。 - -## 存储和认证的具体变化 - -- 删除生产代码中的 `SupabaseStorage` provider 枚举值。 -- 删除应用层所有 Supabase provider 配置路径。 -- 阿里云 OSS SDK 细节只保留在 Infrastructure 实现中。 -- 上传签名和上传确认通过当前租户 object storage provider 解析目标位置。 -- 微信登录不再从公开 JSON 直接读取密钥。 -- 短信登录继续复用自有验证码表、频控和 Session 体系,发送动作改由 `ISmsProvider` 执行。 - -## 数据库基线 - -项目尚无正式业务数据,因此本阶段继续采用 greenfield migration: - -- 删除旧 `TenantAuthProvider` 表。 -- 删除旧 `TenantPaymentAccount` 表。 -- 新增 `TenantExternalProvider` 表。 -- 保留并扩展 `TenantSecret` 用途。 -- 重建唯一 `InitialSchema` migration。 -- 使用空 PostgreSQL 通过 `Tiku.DbMigrator` 验证完整建库。 - -## 架构防线 - -架构测试需要阻止业务代码重新引入以下依赖: - -- `Supabase` -- `supabase_storage` -- `SUPABASE_` -- `TenantAuthProvider` -- `TenantPaymentAccount` -- 业务层直接引用阿里云 OSS、微信、支付或短信 SDK namespace -- 业务层直接读取第三方密钥 -- 业务 DTO 暴露任意 bucket / provider 细节 - -允许例外只应存在于: - -- Infrastructure provider 实现 -- EF Migration -- 测试 fake provider -- 明确说明历史迁移背景的文档 - -## 验收结果 - -本阶段完成时的验收口径: - -- `dotnet build TIKU-BACKEND.slnx --no-restore` -- `dotnet test Tiku.UnitTests/Tiku.UnitTests.csproj --no-restore` -- `dotnet test Tiku.IntegrationTests/Tiku.IntegrationTests.csproj --no-restore` -- 空 PostgreSQL 数据库执行 `Tiku.DbMigrator` -- `git diff --check` - -这些命令的目标不是证明“没有 Supabase 字符串”,而是证明新 Provider 模型、租户边界、迁移链和集成测试能共同支撑后续开发。 - -## 后续扩展规则 - -新增外部服务 provider 时,必须先判断它属于哪个 `Capability`,再补 Infrastructure provider 实现和租户配置解析。不要为每个第三方服务新建一套业务专用账号表;除非该能力已经形成独立领域模型,否则默认进入 `TenantExternalProvider`。 - -Provider 扩展时还必须同时补: - -- 租户 A/B 配置隔离测试。 -- 密钥只通过 `TenantSecret` 读取的测试。 -- `ConfigPublic` 敏感字段拒绝测试。 -- 架构扫描允许列表。 -- 空库 migration 验证。 +```bash +dotnet build TIKU-BACKEND.slnx --no-restore +dotnet test Tiku.UnitTests/Tiku.UnitTests.csproj --no-build +dotnet test Tiku.IntegrationTests/Tiku.IntegrationTests.csproj --no-build +dotnet ef migrations script --project Tiku.Infrastructure --startup-project Tiku.DbMigrator +git diff --check +``` diff --git a/docs/migration/phase-5-backoffice-worker-operations.md b/docs/migration/phase-5-backoffice-worker-operations.md index 1fc7f76..8e2fd5b 100644 --- a/docs/migration/phase-5-backoffice-worker-operations.md +++ b/docs/migration/phase-5-backoffice-worker-operations.md @@ -1,194 +1,47 @@ -# 阶段五:后台能力底座、Worker 基座与运营闭环 +# 第五阶段:后台能力与 Worker 基座 -状态:已完成第一轮底座实现并通过本地验收(2026-07-28)。 +状态:已完成第一轮底座实现。 -本阶段从核心 SaaS 架构重构进入后台运营能力补齐。目标不是逐字兼容旧 NestJS,而是在当前 ASP.NET Core + EF Core + PostgreSQL 架构下重建后台权限、菜单、审计、交易运营和 Worker 基座,并参考 yudao 的后台能力清单补齐系统底座。 +## 目标 -## 固定 SDK 选型 +- 参考 yudao 后台能力,重建权限、菜单、审计、交易运营和 Worker 基座。 +- 微信生态统一使用 `Senparc.Weixin.*`。 +- 业务层继续只依赖 Application 接口,不直接引用第三方 SDK。 -微信生态统一使用 Senparc: +## 结果 -- `Senparc.Weixin` -- `Senparc.Weixin.MP` -- `Senparc.Weixin.WxOpen` -- `Senparc.Weixin.TenPayV3` +- 微信支付切换到 `Senparc.Weixin.TenPayV3`。 +- 生产代码禁止 `SKIT.FlurlHttpClient.Wechat.*`。 +- 新增平台/租户后台权限、菜单、角色和用户角色绑定模型。 +- 菜单只控制 UI 展示,不作为 API 鉴权依据。 +- 角色、权限、菜单和用户角色绑定写操作落 `AuditLog`。 +- 租户交易运营补齐退款、对账批次、对账 issue 和事件记录。 +- 新增 `BackgroundJob` 统一任务模型。 +- Worker 基于 `Microsoft.Extensions.Hosting` + `BackgroundService`。 +- 任务处理器骨架覆盖 `content_export`、`content_import`、`asset_security_scan`、`statistics_aggregation`、`commerce_reconciliation`、`tenant_domain_recheck`。 +- PostgreSQL tenant guard 统一放入集中 SQL helper,由 migration 调用。 -已移除并禁止: - -- `SKIT.FlurlHttpClient.Wechat.*` - -其他外部服务: +## 固定 SDK - OSS:`AlibabaCloud.OSS.V2` - 阿里云短信:`AlibabaCloud.SDK.Dysmsapi20170525` - 支付宝:`AlipaySDKNet.Standard` -- Worker:`Microsoft.Extensions.Hosting` + `BackgroundService` +- 微信公众号:`Senparc.Weixin.MP` +- 微信小程序:`Senparc.Weixin.WxOpen` +- 微信支付 V3:`Senparc.Weixin.TenPayV3` -业务层仍然只依赖 Application 接口: +## 验收 -- `IIdentityProvider` -- `IObjectStorageService` -- `ISmsProvider` -- `IPaymentProvider` -- `INotificationProvider` - -第三方 SDK namespace 只允许出现在 Infrastructure provider 实现中。 - -## 5A:微信 Provider 收敛 - -`WechatPayProvider` 已切换到 `Senparc.Weixin.TenPayV3`: - -- JSAPI / H5 下单参数生成走 Senparc TenPayV3。 -- JSAPI 前端支付参数使用 Senparc 签名 helper 生成。 -- 支付 provider 继续通过 `IPaymentProvider` 暴露,不向 Application 或 API 泄漏 Senparc 类型。 -- 架构测试禁止生产代码引用 `SKIT.FlurlHttpClient.Wechat`。 - -说明:当前 `IPaymentProvider` 的回调入口抽象为 body + headers,不直接暴露 `HttpContext`。Senparc 官方推荐的 `TenPayNotifyHandler(HttpContext)` 回调验签/解密模式后续可以在 Infrastructure 的 HTTP 适配层补强,但不能把 Senparc 类型扩散到业务层。 - -## 5B:后台权限、菜单、角色与审计底座 - -新增后台基础模型: - -- `BackendPermission` -- `BackendMenu` -- `TenantBackendRole` -- `TenantBackendRolePermission` -- `TenantBackendRoleMenu` -- `TenantBackendUserRole` -- `PlatformBackendRole` -- `PlatformBackendRolePermission` -- `PlatformBackendRoleMenu` -- `PlatformBackendUserRole` - -设计边界: - -- 权限点是稳定字符串 code。 -- 菜单只控制后台 UI 展示,不作为唯一 API 鉴权来源。 -- 平台角色和租户角色分表。 -- 租户角色绑定包含 `TenantId`,平台角色绑定不带租户键。 -- 角色、权限、菜单、用户角色绑定写操作落 `AuditLog`。 - -新增接口: - -- `GET /api/backoffice/tenant/bootstrap` -- `POST /api/backoffice/tenant/roles` -- `PUT /api/backoffice/tenant/roles/{roleId}/bindings` -- `PUT /api/backoffice/tenant/users/{userId}/roles` -- `GET /api/backoffice/platform/bootstrap` -- `POST /api/backoffice/platform/roles` -- `PUT /api/backoffice/platform/roles/{roleId}/bindings` -- `PUT /api/backoffice/platform/users/{userId}/roles` - -`TenantAdminDirect` 继续作为过渡入口;新增后台能力使用 `backoffice` 模块命名。 - -## 5E:交易运营底座 - -在现有 commerce 模型上补齐租户后台运营服务: - -- 退款申请。 -- 退款状态流转。 -- 退款事件记录。 -- 对账批次创建。 -- 对账 issue 查询与状态流转。 -- 退款、对账写操作落审计。 - -退款状态流转由服务控制,不能任意跳转。支付、回调、退款后续仍统一走 `IPaymentProvider`,初期不默认开启真实自动退款。 - -新增租户交易运营接口: - -- `GET /api/tenant-commerce/refunds` -- `POST /api/tenant-commerce/refunds` -- `POST /api/tenant-commerce/refunds/status` -- `GET /api/tenant-commerce/refunds/{refundRequestId}/events` -- `GET /api/tenant-commerce/reconciliation/batches` -- `POST /api/tenant-commerce/reconciliation/batches` -- `GET /api/tenant-commerce/reconciliation/issues` -- `POST /api/tenant-commerce/reconciliation/issues/status` - -## 5F:Worker 与后台任务基座 - -新增统一任务模型 `BackgroundJob`: - -- `JobType` -- `TenantId` -- `Payload` -- `Status` -- `RetryCount` -- `MaxRetries` -- `LockedBy` -- `LockExpiresAt` -- `RunAfter` -- `StartedAt` -- `CompletedAt` -- `LastError` -- `OutputAssetId` -- `Result` - -Worker 基于 `Microsoft.Extensions.Hosting` + `BackgroundService`,暂不引入 Hangfire。后续需要复杂 cron 时再评估 `Quartz.Extensions.Hosting`。 - -当前任务处理器建立了幂等租户 Scope 入口和状态机骨架,覆盖以下 job type: - -- `content_export` -- `content_import` -- `asset_security_scan` -- `statistics_aggregation` -- `commerce_reconciliation` -- `tenant_domain_recheck` - -新增租户后台任务接口: - -- `GET /api/backoffice/tenant/jobs` -- `POST /api/backoffice/tenant/jobs` - -API 只负责创建任务和查询任务;Worker 必须通过 `ITenantExecutionScope` 初始化执行上下文。 - -## 阿里云短信 Provider - -`ISmsProvider` 默认接入租户 Provider 配置: - -- 未配置短信 provider 时,本地/测试降级为 `noop`。 -- 配置 `aliyun_sms` 时,从 `TenantExternalProvider(capability=sms)` 读取公开配置。 -- `signName`、`templateCode`、`endpoint`、`regionId` 等公开字段放 `ConfigPublic`。 -- `accessKeyId`、`accessKeySecret` 只允许通过 `TenantSecret` 解密获得。 -- 发送失败会记录 `SmsVerificationStatus.Failed`,不会留下可验证验证码。 - -## 数据库约束补强 - -阶段五重建 `InitialSchema` 时同步补齐阶段三遗漏的两个 PostgreSQL 硬约束: - -- `TenantQuestionReference` 只能引用平台主体公共题或当前租户私题。 -- `TaxonomyNode` 父节点只能属于平台主体或当前租户。 - -这类跨表租户不变量不能靠 EF Core FK / check constraint 自动表达,统一放在 `PostgreSqlTenantConstraintSql`,由 migration 调用,并由真实 PostgreSQL 集成测试覆盖。 - -## 架构测试 - -新增或强化禁止项: - -- 生产代码不得引用 `SKIT.FlurlHttpClient.Wechat`。 -- 业务层不得直接引用阿里云 OSS、阿里云短信、Senparc、支付宝 SDK namespace。 -- 生产代码不得回流 Supabase provider、Supabase Storage 或旧专用 provider 表模型。 -- 普通业务目录继续禁止 `IgnoreQueryFilters`、`FromSql`、`ExecuteSql` 和直接 `NpgsqlCommand`。 - -## 验收结果 - -本阶段本地验收: +- platform token / tenant token 后台权限不能串用。 +- 高风险写操作都有审计。 +- 租户 A 不能查询或处理租户 B 交易数据。 +- Worker 必须通过 `ITenantExecutionScope` 初始化租户或 System Scope。 +- 架构扫描禁止 Supabase、SKIT 微信支付、业务层第三方 SDK、`IgnoreQueryFilters`、`FromSql`、`ExecuteSql`、直接 `NpgsqlCommand`。 ```bash -dotnet restore TIKU-BACKEND.slnx dotnet build TIKU-BACKEND.slnx --no-restore dotnet test Tiku.UnitTests/Tiku.UnitTests.csproj --no-build dotnet test Tiku.IntegrationTests/Tiku.IntegrationTests.csproj --no-build dotnet ef migrations script --project Tiku.Infrastructure --startup-project Tiku.DbMigrator git diff --check ``` - -结果: - -- build:0 警告,0 错误。 -- unit tests:18/18 通过。 -- integration tests:264/264 通过。 -- migration script:生成成功。 -- 空 PostgreSQL 通过 `Tiku.DbMigrator` 建库成功。 -- PostgreSQL 验证两个租户约束 trigger 已创建。 - diff --git a/docs/migration/phase-7-student-experience-and-content-consumption.md b/docs/migration/phase-7-student-experience-and-content-consumption.md index e682a14..fbc7608 100644 --- a/docs/migration/phase-7-student-experience-and-content-consumption.md +++ b/docs/migration/phase-7-student-experience-and-content-consumption.md @@ -1,12 +1,15 @@ # 第七阶段:学生端体验与内容消费闭环 -第七阶段只补齐学生端和内容消费闭环,不实现 AI 推荐报告。AI 后续单独进入 Semantic Kernel 阶段,租户自己的模型 API Key 通过 `TenantExternalProvider` + `TenantSecret` 配置,业务 DTO、Controller 和普通 Service 不直接接触密钥或 `Microsoft.SemanticKernel` namespace。 +状态:已完成。 -## 已实现范围 +## 目标 -### 视频消费 +- 补齐学生端视频消费、Profile 签到、积分流水和内容导入异步化。 +- 不实现 AI 业务功能;AI 进入第八阶段。 -新增学生端视频接口: +## 结果 + +学生端视频接口: - `GET /api/videos/search` - `POST /api/videos/play` @@ -14,62 +17,38 @@ - `GET /api/questions/videos` - `POST /api/questions/videos/batch` -设计边界: - -- 播放接口只返回当前租户可访问的视频播放信息。 -- API 不暴露 OSS bucket、真实 object key 或 provider 细节。 -- 公共题关联视频和租户私题关联视频都必须先通过当前租户可见性校验。 -- 播放行为写入 `ContentAssetAccessEvent`。 -- 播放进度写入 `VideoPlaybackProgress`,同一租户、用户、视频、题目维度幂等更新。 - -### Profile 与积分 - -新增学生侧体验接口: +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 接口查询。 +- 播放接口不暴露 OSS bucket、真实 object key 或 provider 细节。 +- 公共题和租户私题关联视频都必须按当前租户可见性校验。 +- 播放进度按租户、用户、视频、题目维度幂等更新。 +- 签到复用积分任务和积分流水;同一用户、同一租户、同一天只能成功一次。 +- `questions`、`vocabulary`、`handbook`、`scoreline`、`videos` 导入语义统一映射为 `importType`。 +- 大批量导入创建 `content_import` 后台任务,Worker 使用 `ITenantExecutionScope` 执行。 +- 本阶段未实现 `/api/ai/**`。SK 包和 AI provider 边界在第八阶段引入。 -## AI 延后约定 +## 旧接口替代 -本阶段不新增 `/api/ai/**`,不引入 `Microsoft.SemanticKernel` NuGet 包,也不实现真实模型调用、prompt、RAG、导出或推荐算法。 +- `/api/profile/activity-tasks` -> `/api/points/tasks` +- `/api/profile/exchange-items` -> `/api/points/exchange-items` +- `/api/profile/exchange-items/redeem` -> `/api/points/exchange-orders` -后续 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` job,Worker 成功写入结果。 -- 本阶段不得新增 `Microsoft.SemanticKernel` 引用。 diff --git a/docs/migration/phase-8-ai-foundation.md b/docs/migration/phase-8-ai-foundation.md index 4b3487e..f9f4aa4 100644 --- a/docs/migration/phase-8-ai-foundation.md +++ b/docs/migration/phase-8-ai-foundation.md @@ -1,144 +1,61 @@ # 第八阶段:AI 底座与教师端对话 -第八阶段从“只预留 AI”进入 AI 底座建设。AI 当前只面向租户教师和后台运营,不开放给学生端。 +状态:基础包和边界已引入,业务接口待实现。 -## 当前 AI 使用场景 +## 使用场景 -### 1. 租户教师 AI 对话 - -第一批先做基础对话能力: +### 租户教师 AI 对话 - 教师在租户后台发起对话。 -- AI 根据当前租户配置调用模型。 +- AI 根据当前租户 Provider 配置调用模型。 - 对话历史按租户和教师隔离保存。 -- 响应不暴露模型 API Key、Provider 原始响应密钥、内部租户 ID 或对象存储细节。 -- 后续可追加 function calling,用于读取或操作受控业务能力。 +- 后续预留 function calling,但只能调用受审计的后端业务函数。 +- function 有写操作时必须复用 RBAC、DataScope、Tenant Scope 和 AuditLog。 -预留 function call 的原则: +### AI 审核题目反馈 -- Controller 不直接暴露任意 tool/function 名称给客户端调用。 -- Application 层定义可用业务函数目录,例如题目检索、题目草稿生成、班级学习概览、学生跟进建议。 -- Infrastructure 用 Semantic Kernel 把受审计的业务函数注册为 plugin。 -- 每个 function call 都必须记录租户、教师、会话、函数名、输入摘要、结果状态和耗时。 -- 有写操作的 function 必须复用现有 RBAC、DataScope、Tenant Scope 和 AuditLog,不允许 AI 绕过后台权限。 - -### 对话存储模型 - -不要把 Semantic Kernel 的 `ChatHistory`、`ChatMessageContent`、`KernelContent`、tool call object graph 或 provider 原始 response 直接作为 EF Core 持久化模型。原因: - -- SK 的对象模型适合运行时编排,不适合作为长期数据库 schema。 -- OpenAI-compatible provider 的消息格式并不完全等价;DeepSeek 这类接口对 `role`、`content`、`tool_calls`、`tool_call_id` 的结构要求更严格。 -- 如果把 SK metadata、内部 content item 或历史 tool 结构原样回放给 DeepSeek,容易触发请求参数错误。 -- 后续换 provider、增加 function call 或做消息压缩时,直接持久化 SK 对象会变成强耦合。 - -数据库只保存 provider-neutral 的规范化消息: - -- `AiConversation` - - `TenantId` - - `TeacherUserId` - - `Title` - - `Scenario`:`teacher_chat`、后续可扩展。 - - `ProviderCode` - - `Model` - - `Status` - - `Metadata` -- `AiConversationMessage` - - `TenantId` - - `ConversationId` - - `Sequence` - - `Role`:固定为 `system`、`user`、`assistant`、`tool`。 - - `ContentText` - - `ToolCallId` - - `ToolName` - - `ToolArguments` - - `ToolResultSummary` - - `ProviderMessageId` - - `TokenInput` - - `TokenOutput` - - `Metadata` -- `AiToolCallLog` - - `TenantId` - - `ConversationId` - - `MessageId` - - `ToolCallId` - - `ToolName` - - `InputSummary` - - `ResultStatus` - - `DurationMs` - - `ErrorCode` - -运行时转换规则: - -1. Application 层读取规范化消息,不产生 SK 类型。 -2. Infrastructure adapter 把规范化消息转换成 Semantic Kernel `ChatHistory`。 -3. DeepSeek/OpenAI-compatible adapter 只发送 provider 接受的字段: - - 普通消息:`role + content`。 - - assistant tool call:`role=assistant + tool_calls`。 - - tool 结果:`role=tool + tool_call_id + content`。 -4. Provider 原始响应只保存必要摘要和可审计 ID,不作为下一轮请求的直接输入。 -5. 任何无法被目标 provider 表达的 SK metadata 都必须丢弃或写入内部 `Metadata`,不得回放给模型 API。 - -### 2. AI 审核题目反馈 - -这个场景保持简单,不做复杂扩展: - -- 输入:题目反馈内容、题目基本信息、反馈类型、提交用户上下文摘要。 +- 输入:题目反馈、题目摘要、反馈类型、提交用户上下文摘要。 - 输出:审核建议、风险等级、归类标签、是否建议人工复核。 - 不做 function calling。 - 不直接修改题目、反馈状态或用户数据。 -- 只生成建议结果,最终状态变更仍由教师或运营人员确认。 + +## 存储模型 + +不要把 Semantic Kernel 的 `ChatHistory`、`ChatMessageContent`、`KernelContent`、tool call object graph 或 provider 原始 response 作为 EF Core 持久化模型。 + +数据库保存 provider-neutral 消息: + +- `AiConversation`:租户、教师、标题、场景、Provider、模型、状态、metadata。 +- `AiConversationMessage`:租户、会话、序号、role、文本、tool call id/name/arguments/result summary、token、metadata。 +- `AiToolCallLog`:租户、会话、消息、函数名、输入摘要、结果、耗时、错误码。 +- `AiFeedbackReview`:租户、题目反馈、建议、风险、标签、人工复核标记、metadata。 + +运行时由 Infrastructure adapter 把规范化消息转换为 SK / OpenAI-compatible 请求。DeepSeek 等 provider 只接收目标接口允许的 `role`、`content`、`tool_calls`、`tool_call_id` 字段;SK metadata 不得原样回放给模型 API。 ## Provider 与密钥边界 - AI Provider 使用 `TenantExternalProvider(capability=ai)`。 -- 租户自己的模型 API Key 存入 `TenantSecret`,通过 `SecretRef` 关联。 -- `ConfigPublic` 只允许保存公开配置,例如 provider、model、endpoint、deployment、temperature 默认值、max token 限制。 -- `ConfigPublic` 禁止出现 `secret`、`token`、`apiKey`、`key`、`privateKey` 等敏感字段。 -- `Microsoft.SemanticKernel` NuGet 包只引用在 `Tiku.Infrastructure`。 +- 租户 API Key 存 `TenantSecret`,通过 `SecretRef` 关联。 +- `ConfigPublic` 只允许 provider、model、endpoint、deployment、temperature、max token 等公开配置。 +- `ConfigPublic` 禁止 `secret`、`token`、`apiKey`、`key`、`privateKey` 等敏感字段。 +- `Microsoft.SemanticKernel` 只引用在 `Tiku.Infrastructure`。 - `Tiku.Api`、`Tiku.Application`、`Tiku.Domain` 不直接引用 Semantic Kernel namespace。 -## 建议模块边界 +## 第一批接口 -Application 层后续只放业务抽象: +- `POST /api/tenant-admin/ai/conversations` +- `GET /api/tenant-admin/ai/conversations` +- `GET /api/tenant-admin/ai/conversations/{conversationId}` +- `POST /api/tenant-admin/ai/conversations/{conversationId}/messages` +- `POST /api/tenant-admin/ai/question-feedback/review` -- `IAiConversationService` -- `IAiFeedbackReviewService` -- `IAiKernelFactory` -- `IAiProviderConfigService` - -Infrastructure 层负责: - -- 根据当前租户 Provider 配置和 `TenantSecret` 创建 Kernel。 -- 注册受审计 plugin。 -- 调用 chat completion。 -- 处理 provider 错误、超时、重试和调用日志。 - -Domain 层可增加持久化模型: - -- `AiConversation` -- `AiConversationMessage` -- `AiToolCallLog` -- `AiFeedbackReview` - -## 第一批实施顺序 - -1. 增加 AI Provider capability、Semantic Kernel 包和架构测试边界。 -2. 增加 AI 配置服务测试:租户 A/B 不能互读模型配置和密钥。 -3. 增加教师对话数据模型和最小 API: - - `POST /api/tenant-admin/ai/conversations` - - `GET /api/tenant-admin/ai/conversations` - - `GET /api/tenant-admin/ai/conversations/{conversationId}` - - `POST /api/tenant-admin/ai/conversations/{conversationId}/messages` -4. 增加 fake AI provider,先跑通对话和日志,不接真实模型。 -5. 增加题目反馈审核最小 API: - - `POST /api/tenant-admin/ai/question-feedback/review` -6. 最后再接真实模型 Provider。 +第一批先接 fake/local AI provider 跑通对话、日志和隔离,再接真实模型。 ## 暂不做 - 学生端 AI。 - 复杂 RAG。 -- 自动改题、自动发布题目。 +- 自动改题或自动发布题目。 - 自动处理反馈状态。 - 让客户端指定任意 function call。 -- 生产环境使用明文 API Key 配置。 +- 明文 API Key 配置。