feat: add vocabulary handbook import pipeline

This commit is contained in:
Codex
2026-06-22 00:11:01 +08:00
parent c1159f8745
commit b458317a81
13 changed files with 2448 additions and 186 deletions

View File

@@ -13,7 +13,7 @@
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
- `apps/api` 独立业务 API后续供 H5、Taro 小程序、管理后台统一调用。 - `apps/api` 独立业务 API后续供 H5、Taro 小程序、管理后台统一调用。
- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、审计日志。 - 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、审计日志。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目 JSON 批量导入。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册 JSON 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。
- 平台后台能力租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。 - 平台后台能力租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
@@ -25,13 +25,15 @@
- 正式 Supabase Auth/JWT 鉴权还没替换迁移期请求头。 - 正式 Supabase Auth/JWT 鉴权还没替换迁移期请求头。
- 真实短信、微信登录、QQ 登录、微信支付、支付宝等 provider adapter 还没接完。 - 真实短信、微信登录、QQ 登录、微信支付、支付宝等 provider adapter 还没接完。
- 真实 OSS/COS/Supabase Storage 上传下载签名还没接完。 - 真实 OSS/COS/Supabase Storage 上传下载签名还没接完。
- Excel/CSV 导入、单词/手册/分数线/视频批量导入和异步 worker 还没完成。 - Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。
- Taro 跨端前端还没开始 scaffold。 - Taro 跨端前端还没开始 scaffold。
更完整的进度看这些文档: 更完整的进度看这些文档:
- `docs/refactor/implementation-status.md` - `docs/refactor/implementation-status.md`
- `docs/refactor/backend-progress.md` - `docs/refactor/backend-progress.md`
- `docs/refactor/content-import-contract.md`
- `docs/refactor/next-development-todo.md`
- `docs/refactor/blueprint-coverage.md` - `docs/refactor/blueprint-coverage.md`
- `docs/refactor/api-structure.md` - `docs/refactor/api-structure.md`
@@ -142,7 +144,7 @@ apps/api/src/features/
- 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。 - 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL。 - 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL。
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。 - 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。 - 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册导入已走这套后台校验管线,前端只做预检查和预览展示。
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用。 - 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用。
## 最近一次验证 ## 最近一次验证
@@ -162,7 +164,7 @@ npm run test:api
优先继续补: 优先继续补:
1. 真实对象存储 adapter阿里云 OSS、腾讯云 COS 或 Supabase Storage。 1. 真实对象存储 adapter阿里云 OSS、腾讯云 COS 或 Supabase Storage。
2. Excel/CSV 以及单词、知识手册、分数线、视频批量导入。 2. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行
3. Supabase Auth/JWT 正式鉴权和生产 RLS 验证。 3. Supabase Auth/JWT 正式鉴权和生产 RLS 验证。
4. 微信/QQ 登录、短信、微信支付、支付宝支付 adapter。 4. 微信/QQ 登录、短信、微信支付、支付宝支付 adapter。
5. Taro 前端 scaffold让 H5 和小程序共用同一套 API。 5. Taro 前端 scaffold让 H5 和小程序共用同一套 API。

View File

@@ -321,6 +321,7 @@ export async function vocabularyUnitsRoute(ctx: RequestContext) {
const items = await query( const items = await query(
` `
select id, legacy_id as "legacyId", region_id as "regionId", select id, legacy_id as "legacyId", region_id as "regionId",
entry_id as "entryId", content_node_id as "contentNodeId",
name, description, word_count as "wordCount", name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive", sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt" created_at as "createdAt", updated_at as "updatedAt"
@@ -349,6 +350,7 @@ export async function vocabularyWordsRoute(ctx: RequestContext) {
const items = await query( const items = await query(
` `
select id, legacy_id as "legacyId", unit_id as "unitId", select id, legacy_id as "legacyId", unit_id as "unitId",
entry_id as "entryId", content_node_id as "contentNodeId",
word, phonetic, meaning, example, word, phonetic, meaning, example,
example_translation as "exampleTranslation", example_translation as "exampleTranslation",
difficulty, tags, sort_order as "order", difficulty, tags, sort_order as "order",
@@ -378,6 +380,7 @@ export async function handbookSubjectsRoute(ctx: RequestContext) {
const items = await query( const items = await query(
` `
select id, legacy_id as "legacyId", region_id as "regionId", select id, legacy_id as "legacyId", region_id as "regionId",
entry_id as "entryId", content_node_id as "contentNodeId",
name, type, icon, color, description, sort_order as "order", name, type, icon, color, description, sort_order as "order",
is_active as "isActive", metadata, is_active as "isActive", metadata,
created_at as "createdAt", updated_at as "updatedAt" created_at as "createdAt", updated_at as "updatedAt"
@@ -404,6 +407,7 @@ export async function handbookChaptersRoute(ctx: RequestContext) {
const items = await query( const items = await query(
` `
select id, legacy_id as "legacyId", subject_id as "subjectId", select id, legacy_id as "legacyId", subject_id as "subjectId",
entry_id as "entryId", content_node_id as "contentNodeId",
name, description, sort_order as "order", name, description, sort_order as "order",
is_active as "isActive", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt" created_at as "createdAt", updated_at as "updatedAt"
@@ -431,6 +435,7 @@ export async function handbookEntriesRoute(ctx: RequestContext) {
const items = await query( const items = await query(
` `
select id, legacy_id as "legacyId", chapter_id as "chapterId", select id, legacy_id as "legacyId", chapter_id as "chapterId",
entry_id as "entryId", content_node_id as "contentNodeId",
title, summary, ${includeContent ? 'content' : 'null::text as content'}, title, summary, ${includeContent ? 'content' : 'null::text as content'},
tags, sort_order as "order", is_active as "isActive", tags, sort_order as "order", is_active as "isActive",
created_at as "createdAt", updated_at as "updatedAt" created_at as "createdAt", updated_at as "updatedAt"

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,14 @@ import {
upsertAssetRoute, upsertAssetRoute,
} from './assets.js'; } from './assets.js';
import { import {
importHandbookRoute,
importIssuesRoute, importIssuesRoute,
importJobsRoute, importJobsRoute,
importQuestionsRoute, importQuestionsRoute,
importVocabularyRoute,
previewHandbookImportRoute,
previewQuestionsImportRoute, previewQuestionsImportRoute,
previewVocabularyImportRoute,
} from './imports.js'; } from './imports.js';
import { import {
contentEntriesAdminRoute, contentEntriesAdminRoute,
@@ -66,6 +70,10 @@ export const tenantContentRoutes: RouteDefinition[] = [
['POST', '/api/tenant-content/assets/sign-download', signAssetDownloadAdminRoute], ['POST', '/api/tenant-content/assets/sign-download', signAssetDownloadAdminRoute],
['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute], ['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute],
['POST', '/api/tenant-content/imports/questions', importQuestionsRoute], ['POST', '/api/tenant-content/imports/questions', importQuestionsRoute],
['POST', '/api/tenant-content/imports/preview/vocabulary', previewVocabularyImportRoute],
['POST', '/api/tenant-content/imports/vocabulary', importVocabularyRoute],
['POST', '/api/tenant-content/imports/preview/handbook', previewHandbookImportRoute],
['POST', '/api/tenant-content/imports/handbook', importHandbookRoute],
['GET', '/api/tenant-content/imports', importJobsRoute], ['GET', '/api/tenant-content/imports', importJobsRoute],
['GET', '/api/tenant-content/imports/issues', importIssuesRoute], ['GET', '/api/tenant-content/imports/issues', importIssuesRoute],
['GET', '/api/tenant-content/videos', videosAdminRoute], ['GET', '/api/tenant-content/videos', videosAdminRoute],

View File

@@ -1,4 +1,5 @@
import { HttpError, type RequestContext } from '../../core/http.js'; import { HttpError, type RequestContext } from '../../core/http.js';
import type pg from 'pg';
import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js'; import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js'; import { query, queryOne, transaction } from '../../core/db.js';
import { requireTenantContentEditor } from './auth.js'; import { requireTenantContentEditor } from './auth.js';
@@ -20,6 +21,41 @@ async function assertOptionalReference(
} }
} }
async function resolveOptionalContentNavigation(
client: pg.PoolClient,
tenantId: string,
entryId: string | null,
contentNodeId: string | null,
expectedEntryType: 'vocabulary' | 'handbook',
) {
let resolvedEntryId = entryId;
if (resolvedEntryId) {
const entry = await client.query<{ id: string; entry_type: string }>(
'select id, entry_type from public.content_entries where tenant_id = $1 and id = $2 limit 1',
[tenantId, resolvedEntryId],
);
if (!entry.rows[0]) throw new HttpError(400, 'content entry reference is not in this tenant', 'ENTRY_NOT_FOUND');
if (entry.rows[0].entry_type !== expectedEntryType) {
throw new HttpError(400, `content entry must be ${expectedEntryType}`, 'ENTRY_TYPE_MISMATCH');
}
}
if (contentNodeId) {
const node = await client.query<{ id: string; entry_id: string }>(
'select id, entry_id from public.content_nodes where tenant_id = $1 and id = $2 limit 1',
[tenantId, contentNodeId],
);
if (!node.rows[0]) throw new HttpError(400, 'content node reference is not in this tenant', 'CONTENT_NODE_NOT_FOUND');
if (resolvedEntryId && node.rows[0].entry_id !== resolvedEntryId) {
throw new HttpError(400, 'content node is not under the selected entry', 'CONTENT_NODE_ENTRY_MISMATCH');
}
resolvedEntryId = resolvedEntryId || node.rows[0].entry_id;
}
return { entryId: resolvedEntryId, contentNodeId };
}
async function syncPrimaryCollectionItem( async function syncPrimaryCollectionItem(
client: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> }, client: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> },
tenantId: string, tenantId: string,
@@ -652,7 +688,8 @@ export async function vocabularyUnitsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx); const auth = await requireTenantContentEditor(ctx);
const items = await query( const items = await query(
` `
select id, region_id as "regionId", name, description, word_count as "wordCount", select id, region_id as "regionId", entry_id as "entryId",
content_node_id as "contentNodeId", name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt" sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
from public.vocabulary_units from public.vocabulary_units
where tenant_id = $1 where tenant_id = $1
@@ -666,35 +703,53 @@ export async function vocabularyUnitsAdminRoute(ctx: RequestContext) {
export async function upsertVocabularyUnitRoute(ctx: RequestContext) { export async function upsertVocabularyUnitRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx); const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx); const body = await readJsonBody(ctx);
const item = await queryOne( const item = await transaction(async client => {
` const navigation = await resolveOptionalContentNavigation(
insert into public.vocabulary_units ( client,
id, tenant_id, region_id, legacy_id, name, description, word_count, sort_order, is_active
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9)
on conflict (id)
do update set region_id = excluded.region_id,
name = excluded.name,
description = excluded.description,
word_count = excluded.word_count,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, region_id as "regionId", name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId, auth.tenantId,
nullableString(body.regionId), nullableString(body.entryId),
nullableString(body.legacyId), nullableString(body.contentNodeId),
requiredString(body, 'name'), 'vocabulary',
nullableString(body.description), );
body.wordCount === undefined ? null : intValue(body.wordCount, 0), const result = await client.query(
intValue(body.order, 0), `
boolValue(body.isActive, true), insert into public.vocabulary_units (
], id, tenant_id, region_id, entry_id, content_node_id, legacy_id,
); name, description, word_count, sort_order, is_active, metadata
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11, $12::jsonb)
on conflict (id)
do update set region_id = excluded.region_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
name = excluded.name,
description = excluded.description,
word_count = excluded.word_count,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, region_id as "regionId", entry_id as "entryId",
content_node_id as "contentNodeId", name, description, word_count as "wordCount",
sort_order as "order", is_active as "isActive", metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
navigation.entryId,
navigation.contentNodeId,
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.description),
body.wordCount === undefined ? null : intValue(body.wordCount, 0),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
return result.rows[0];
});
return { item }; return { item };
} }
@@ -704,7 +759,8 @@ export async function vocabularyWordsAdminRoute(ctx: RequestContext) {
const limit = intParam(ctx, 'limit', 500, 2000); const limit = intParam(ctx, 'limit', 500, 2000);
const items = await query( const items = await query(
` `
select id, unit_id as "unitId", word, phonetic, meaning, example, select id, unit_id as "unitId", entry_id as "entryId",
content_node_id as "contentNodeId", word, phonetic, meaning, example,
example_translation as "exampleTranslation", difficulty, tags, example_translation as "exampleTranslation", difficulty, tags,
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt" sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
from public.vocabulary_words from public.vocabulary_words
@@ -720,46 +776,76 @@ export async function vocabularyWordsAdminRoute(ctx: RequestContext) {
export async function upsertVocabularyWordRoute(ctx: RequestContext) { export async function upsertVocabularyWordRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx); const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx); const body = await readJsonBody(ctx);
const item = await queryOne( const item = await transaction(async client => {
` const unitId = nullableString(body.unitId);
insert into public.vocabulary_words ( let inheritedEntryId: string | null = null;
id, tenant_id, unit_id, legacy_id, word, phonetic, meaning, let inheritedContentNodeId: string | null = null;
example, example_translation, difficulty, tags, sort_order, is_active if (unitId) {
) const unit = await client.query<{ id: string; entry_id: string | null; content_node_id: string | null }>(
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13) 'select id, entry_id, content_node_id from public.vocabulary_units where tenant_id = $1 and id = $2 limit 1',
on conflict (id) [auth.tenantId, unitId],
do update set unit_id = excluded.unit_id, );
word = excluded.word, if (!unit.rows[0]) throw new HttpError(400, 'unitId is not in this tenant', 'VOCABULARY_UNIT_NOT_FOUND');
phonetic = excluded.phonetic, inheritedEntryId = unit.rows[0].entry_id;
meaning = excluded.meaning, inheritedContentNodeId = unit.rows[0].content_node_id;
example = excluded.example, }
example_translation = excluded.example_translation, const navigation = await resolveOptionalContentNavigation(
difficulty = excluded.difficulty, client,
tags = excluded.tags,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, unit_id as "unitId", word, phonetic, meaning,
example, example_translation as "exampleTranslation",
difficulty, tags, sort_order as "order", is_active as "isActive",
updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId, auth.tenantId,
nullableString(body.unitId), nullableString(body.entryId) || inheritedEntryId,
nullableString(body.legacyId), nullableString(body.contentNodeId) || inheritedContentNodeId,
requiredString(body, 'word'), 'vocabulary',
nullableString(body.phonetic), );
nullableString(body.meaning), const result = await client.query(
nullableString(body.example), `
nullableString(body.exampleTranslation), insert into public.vocabulary_words (
body.difficulty === undefined ? null : intValue(body.difficulty, 1), id, tenant_id, unit_id, entry_id, content_node_id, legacy_id, word,
jsonArrayValue(body.tags), phonetic, meaning, example, example_translation, difficulty, tags,
intValue(body.order, 0), sort_order, is_active, metadata
boolValue(body.isActive, true), )
], values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16::jsonb)
); on conflict (id)
do update set unit_id = excluded.unit_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
word = excluded.word,
phonetic = excluded.phonetic,
meaning = excluded.meaning,
example = excluded.example,
example_translation = excluded.example_translation,
difficulty = excluded.difficulty,
tags = excluded.tags,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, unit_id as "unitId", entry_id as "entryId",
content_node_id as "contentNodeId", word, phonetic, meaning,
example, example_translation as "exampleTranslation",
difficulty, tags, sort_order as "order", is_active as "isActive",
metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
unitId,
navigation.entryId,
navigation.contentNodeId,
nullableString(body.legacyId),
requiredString(body, 'word'),
nullableString(body.phonetic),
nullableString(body.meaning),
nullableString(body.example),
nullableString(body.exampleTranslation),
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
jsonArrayValue(body.tags),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
return result.rows[0];
});
return { item }; return { item };
} }
@@ -767,7 +853,8 @@ export async function handbookSubjectsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx); const auth = await requireTenantContentEditor(ctx);
const items = await query( const items = await query(
` `
select id, region_id as "regionId", name, type, icon, color, select id, region_id as "regionId", entry_id as "entryId",
content_node_id as "contentNodeId", name, type, icon, color,
description, sort_order as "order", is_active as "isActive", metadata description, sort_order as "order", is_active as "isActive", metadata
from public.handbook_subjects from public.handbook_subjects
where tenant_id = $1 where tenant_id = $1
@@ -781,43 +868,58 @@ export async function handbookSubjectsAdminRoute(ctx: RequestContext) {
export async function upsertHandbookSubjectRoute(ctx: RequestContext) { export async function upsertHandbookSubjectRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx); const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx); const body = await readJsonBody(ctx);
const item = await queryOne( const item = await transaction(async client => {
` const navigation = await resolveOptionalContentNavigation(
insert into public.handbook_subjects ( client,
id, tenant_id, region_id, legacy_id, name, type, icon, color,
description, sort_order, is_active, metadata
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
on conflict (id)
do update set region_id = excluded.region_id,
name = excluded.name,
type = excluded.type,
icon = excluded.icon,
color = excluded.color,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, region_id as "regionId", name, type, icon, color,
description, sort_order as "order", is_active as "isActive",
metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId, auth.tenantId,
nullableString(body.regionId), nullableString(body.entryId),
nullableString(body.legacyId), nullableString(body.contentNodeId),
requiredString(body, 'name'), 'handbook',
nullableString(body.type), );
nullableString(body.icon), const result = await client.query(
nullableString(body.color), `
nullableString(body.description), insert into public.handbook_subjects (
intValue(body.order, 0), id, tenant_id, region_id, entry_id, content_node_id, legacy_id,
boolValue(body.isActive, true), name, type, icon, color, description, sort_order, is_active, metadata
jsonObjectValue(body.metadata), )
], values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11, $12, $13, $14::jsonb)
); on conflict (id)
do update set region_id = excluded.region_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
name = excluded.name,
type = excluded.type,
icon = excluded.icon,
color = excluded.color,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, region_id as "regionId", entry_id as "entryId",
content_node_id as "contentNodeId", name, type, icon, color,
description, sort_order as "order", is_active as "isActive",
metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.regionId),
navigation.entryId,
navigation.contentNodeId,
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.type),
nullableString(body.icon),
nullableString(body.color),
nullableString(body.description),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
return result.rows[0];
});
return { item }; return { item };
} }
@@ -826,7 +928,8 @@ export async function handbookChaptersAdminRoute(ctx: RequestContext) {
const subjectId = stringParam(ctx, 'subjectId'); const subjectId = stringParam(ctx, 'subjectId');
const items = await query( const items = await query(
` `
select id, subject_id as "subjectId", name, description, select id, subject_id as "subjectId", entry_id as "entryId",
content_node_id as "contentNodeId", name, description,
sort_order as "order", is_active as "isActive" sort_order as "order", is_active as "isActive"
from public.handbook_chapters from public.handbook_chapters
where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid) where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid)
@@ -840,33 +943,57 @@ export async function handbookChaptersAdminRoute(ctx: RequestContext) {
export async function upsertHandbookChapterRoute(ctx: RequestContext) { export async function upsertHandbookChapterRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx); const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx); const body = await readJsonBody(ctx);
const item = await queryOne( const item = await transaction(async client => {
` const subjectId = requiredString(body, 'subjectId');
insert into public.handbook_chapters ( const subject = await client.query<{ id: string; entry_id: string | null; content_node_id: string | null }>(
id, tenant_id, subject_id, legacy_id, name, description, sort_order, is_active 'select id, entry_id, content_node_id from public.handbook_subjects where tenant_id = $1 and id = $2 limit 1',
) [auth.tenantId, subjectId],
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8) );
on conflict (id) if (!subject.rows[0]) throw new HttpError(400, 'subjectId is not in this tenant', 'HANDBOOK_SUBJECT_NOT_FOUND');
do update set subject_id = excluded.subject_id, const navigation = await resolveOptionalContentNavigation(
name = excluded.name, client,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, subject_id as "subjectId", name, description,
sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId, auth.tenantId,
requiredString(body, 'subjectId'), nullableString(body.entryId) || subject.rows[0].entry_id,
nullableString(body.legacyId), nullableString(body.contentNodeId) || subject.rows[0].content_node_id,
requiredString(body, 'name'), 'handbook',
nullableString(body.description), );
intValue(body.order, 0), const result = await client.query(
boolValue(body.isActive, true), `
], insert into public.handbook_chapters (
); id, tenant_id, subject_id, entry_id, content_node_id, legacy_id,
name, description, sort_order, is_active, metadata
)
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11::jsonb)
on conflict (id)
do update set subject_id = excluded.subject_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
name = excluded.name,
description = excluded.description,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, subject_id as "subjectId", entry_id as "entryId",
content_node_id as "contentNodeId", name, description,
sort_order as "order", is_active as "isActive", metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
subjectId,
navigation.entryId,
navigation.contentNodeId,
nullableString(body.legacyId),
requiredString(body, 'name'),
nullableString(body.description),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
return result.rows[0];
});
return { item }; return { item };
} }
@@ -875,7 +1002,8 @@ export async function handbookEntriesAdminRoute(ctx: RequestContext) {
const chapterId = stringParam(ctx, 'chapterId'); const chapterId = stringParam(ctx, 'chapterId');
const items = await query( const items = await query(
` `
select id, chapter_id as "chapterId", title, summary, content, select id, chapter_id as "chapterId", entry_id as "entryId",
content_node_id as "contentNodeId", title, summary, content,
tags, sort_order as "order", is_active as "isActive" tags, sort_order as "order", is_active as "isActive"
from public.handbook_entries from public.handbook_entries
where tenant_id = $1 and ($2::uuid is null or chapter_id = $2::uuid) where tenant_id = $1 and ($2::uuid is null or chapter_id = $2::uuid)
@@ -889,37 +1017,60 @@ export async function handbookEntriesAdminRoute(ctx: RequestContext) {
export async function upsertHandbookEntryRoute(ctx: RequestContext) { export async function upsertHandbookEntryRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx); const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx); const body = await readJsonBody(ctx);
const item = await queryOne( const item = await transaction(async client => {
` const chapterId = requiredString(body, 'chapterId');
insert into public.handbook_entries ( const chapter = await client.query<{ id: string; entry_id: string | null; content_node_id: string | null }>(
id, tenant_id, chapter_id, legacy_id, title, summary, content, 'select id, entry_id, content_node_id from public.handbook_chapters where tenant_id = $1 and id = $2 limit 1',
tags, sort_order, is_active [auth.tenantId, chapterId],
) );
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8::jsonb, $9, $10) if (!chapter.rows[0]) throw new HttpError(400, 'chapterId is not in this tenant', 'HANDBOOK_CHAPTER_NOT_FOUND');
on conflict (id) const navigation = await resolveOptionalContentNavigation(
do update set chapter_id = excluded.chapter_id, client,
title = excluded.title,
summary = excluded.summary,
content = excluded.content,
tags = excluded.tags,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_at = now()
returning id, chapter_id as "chapterId", title, summary, content,
tags, sort_order as "order", is_active as "isActive", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId, auth.tenantId,
requiredString(body, 'chapterId'), nullableString(body.entryId) || chapter.rows[0].entry_id,
nullableString(body.legacyId), nullableString(body.contentNodeId) || chapter.rows[0].content_node_id,
requiredString(body, 'title'), 'handbook',
nullableString(body.summary), );
nullableString(body.content), const result = await client.query(
jsonArrayValue(body.tags), `
intValue(body.order, 0), insert into public.handbook_entries (
boolValue(body.isActive, true), id, tenant_id, chapter_id, entry_id, content_node_id, legacy_id, title,
], summary, content, tags, sort_order, is_active, metadata
); )
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, $12, $13::jsonb)
on conflict (id)
do update set chapter_id = excluded.chapter_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
title = excluded.title,
summary = excluded.summary,
content = excluded.content,
tags = excluded.tags,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
metadata = excluded.metadata,
updated_at = now()
returning id, chapter_id as "chapterId", entry_id as "entryId",
content_node_id as "contentNodeId", title, summary, content,
tags, sort_order as "order", is_active as "isActive", metadata, updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
chapterId,
navigation.entryId,
navigation.contentNodeId,
nullableString(body.legacyId),
requiredString(body, 'title'),
nullableString(body.summary),
nullableString(body.content),
jsonArrayValue(body.tags),
intValue(body.order, 0),
boolValue(body.isActive, true),
jsonObjectValue(body.metadata),
],
);
return result.rows[0];
});
return { item }; return { item };
} }

View File

@@ -18,11 +18,13 @@
- `scripts/import-pocketbase`PocketBase schema/数据导入工具。 - `scripts/import-pocketbase`PocketBase schema/数据导入工具。
- `docker-compose.api.yml``apps/api/Dockerfile`:本地 Docker API 运行入口。 - `docker-compose.api.yml``apps/api/Dockerfile`:本地 Docker API 运行入口。
- `docs/refactor/architecture.md`:新重构目录边界和工程规范。 - `docs/refactor/architecture.md`:新重构目录边界和工程规范。
- `docs/refactor/content-import-contract.md`:题目、单词、知识手册导入契约,明确后端校验、旧格式转换和前端职责。
- `docs/refactor/next-development-todo.md`后端剩余缺口、Taro 前端接入顺序、上云测试前待办。
下一步优先级: 下一步优先级:
1. 导出 PocketBase 真实数据到 `pb_export/*.json` 1. 导出 PocketBase 真实数据到 `pb_export/*.json`
2. 执行 `npm run pb:import:json``npm run pb:import:validate` 2. 执行 `npm run pb:import:json``npm run pb:import:validate`
3. 按学生端页面逐步从 PocketBase SDK 切换到 `src/services/supabaseApi.ts` 3. 按学生端页面逐步从 PocketBase SDK 切换到 `src/services/supabaseApi.ts`
4.订单、支付、权益开通补齐 API 写入流程和 webhook 幂等处理 4.分数线、视频、Excel/CSV 补齐批量导入,并复用 `content_import_jobs` 管线
5. 新建 Taro 学生端时复用同一套租户解析和业务 API不另起一套后端。 5. 新建 Taro 学生端时复用同一套租户解析和业务 API不另起一套后端。

View File

@@ -15,7 +15,7 @@
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列。 - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列。
- `platform-admin`平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。 - `platform-admin`平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。 - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目 JSON 导入维护。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。
- `tenant`:域名/租户解析。 - `tenant`:域名/租户解析。
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。 - 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
@@ -93,6 +93,10 @@ POST /api/tenant-content/assets/sign-upload
POST /api/tenant-content/assets/sign-download POST /api/tenant-content/assets/sign-download
POST /api/tenant-content/imports/preview/questions POST /api/tenant-content/imports/preview/questions
POST /api/tenant-content/imports/questions POST /api/tenant-content/imports/questions
POST /api/tenant-content/imports/preview/vocabulary
POST /api/tenant-content/imports/vocabulary
POST /api/tenant-content/imports/preview/handbook
POST /api/tenant-content/imports/handbook
GET /api/tenant-content/imports GET /api/tenant-content/imports
GET /api/tenant-content/imports/issues GET /api/tenant-content/imports/issues
PUT /api/tenant-content/videos PUT /api/tenant-content/videos
@@ -173,11 +177,11 @@ GET /api/tenant-admin/audit-logs
- CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。 - CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。
- 内容资源当前完成台账、租户后台维护、上传/下载签名占位和学生端 SVIP 下载权限真实对象存储签名、PDF 预览渲染和防盗链在 provider/worker 中实现。 - 内容资源当前完成台账、租户后台维护、上传/下载签名占位和学生端 SVIP 下载权限真实对象存储签名、PDF 预览渲染和防盗链在 provider/worker 中实现。
- 题库内容导航当前以 `content_entries/content_nodes` 为主模型,可表达“入口 -> 多级分类 -> 院校/专业/学科/销售意向标记”;题目集合和练习方式由 `question_collections/practice_blueprints` 管理,练习 session 会保存当次题目 ID 快照。 - 题库内容导航当前以 `content_entries/content_nodes` 为主模型,可表达“入口 -> 多级分类 -> 院校/专业/学科/销售意向标记”;题目集合和练习方式由 `question_collections/practice_blueprints` 管理,练习 session 会保存当次题目 ID 快照。
- 题目批量导入当前支持 JSON 数组预览、逐行 issue、job/item 台账、执行导入、幂等跳过,并可落到新内容入口分类节点和题目集合;Excel/CSV、单词/手册/分数线导入会复用同一套 `content_import_jobs` 管线。 - 批量导入当前支持题目、单词、知识手册 JSON 预览、逐行 issue、job/item 台账、执行导入、幂等跳过,并可落到新内容入口分类节点。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。Excel/CSV、分数线/视频导入会继续复用同一套 `content_import_jobs` 管线。
## 下一步 ## 下一步
1. 完善内容导入和文件上传Excel/CSV、单词、手册、分数线、视频导入,真实 OSS/COS/Supabase Storage 签名。 1. 完善内容导入和文件上传Excel/CSV、分数线、视频导入真实 OSS/COS/Supabase Storage 签名。
2. 接入真实短信 provider阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。 2. 接入真实短信 provider阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
3. 接入真实 OAuth provider微信网页、微信小程序、QQ并处理旧 PocketBase 身份映射。 3. 接入真实 OAuth provider微信网页、微信小程序、QQ并处理旧 PocketBase 身份映射。
4. 增加真实支付 providerXPay、微信支付、支付宝并完善 webhook 幂等。 4. 增加真实支付 providerXPay、微信支付、支付宝并完善 webhook 幂等。

View File

@@ -18,10 +18,10 @@
| 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略、地区/全国套餐权限、平台侧主题模板库、平台审计 | | 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略、地区/全国套餐权限、平台侧主题模板库、平台审计 |
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 | | 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
| 租户成员权限 | 基础完成 | owner/admin/operator/teacher/sales/agent/student权限矩阵成员启停审计查询 | 前端权限 UI、自定义角色模板、菜单级可见配置 | | 租户成员权限 | 基础完成 | owner/admin/operator/teacher/sales/agent/student权限矩阵成员启停审计查询 | 前端权限 UI、自定义角色模板、菜单级可见配置 |
| 题库内容维护 | 基础完成 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目 JSON 预览/导入、视频绑定、分数线、单词、知识手册后台 API | Excel/CSV 批量导入、公题库采纳/复制/授权、可视化拖拽排序前端 | | 题库内容维护 | 基础完成 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目 JSON 预览/导入、视频绑定、分数线、单词、知识手册后台 API | Excel/CSV 批量导入、分数线/视频导入、公题库采纳/复制/授权、可视化拖拽排序前端 |
| 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹 | 完整模考交卷评分报告、专项练习策略、错题复习计划、题型统计深度分析 | | 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹 | 完整模考交卷评分报告、专项练习策略、错题复习计划、题型统计深度分析 |
| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计 | 复习算法、每日计划、排行榜 | | 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、旧模板/新模板 JSON 预览导入、内容导航绑定 | 复习算法、每日计划、排行榜、Excel 导入 |
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护 | 富文本资源、版本管理、附件/PDF 关联 | | 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 |
| 分数线 | 基础完成 | 字段、院校、专业、记录、趋势、年份 | 复杂动态筛选、批量导入、AI 择校数据上下文 | | 分数线 | 基础完成 | 字段、院校、专业、记录、趋势、年份 | 复杂动态筛选、批量导入、AI 择校数据上下文 |
| 视频解析会员 | 部分完成 | 题目视频、批量查询、后台绑定 | SVIP 权限、播放次数扣减、签名 URL、防盗链、水印、播放统计 | | 视频解析会员 | 部分完成 | 题目视频、批量查询、后台绑定 | SVIP 权限、播放次数扣减、签名 URL、防盗链、水印、播放统计 |
| 资料下载/PDF | 基础完成 | `content_assets` 资源台账、后台资源管理、上传/下载签名占位、学生端列表、SVIP 下载权限 | 真实 OSS/COS/Supabase Storage 签名、PDF 预览渲染、防盗链、资料前端管理页 | | 资料下载/PDF | 基础完成 | `content_assets` 资源台账、后台资源管理、上传/下载签名占位、学生端列表、SVIP 下载权限 | 真实 OSS/COS/Supabase Storage 签名、PDF 预览渲染、防盗链、资料前端管理页 |
@@ -36,7 +36,7 @@
## 接下来优先级 ## 接下来优先级
1. 完善内容导入和对象存储Excel/CSV、单词/手册/分数线/视频导入,真实 OSS/COS/Supabase Storage 签名。 1. 完善内容导入和对象存储Excel/CSV、分数线/视频导入,真实 OSS/COS/Supabase Storage 签名JSON 导入异步化
2. 公共题库/地区题库授权:平台题库向租户披露、租户采纳、按 SaaS 套餐限制地区。 2. 公共题库/地区题库授权:平台题库向租户披露、租户采纳、按 SaaS 套餐限制地区。
3. 完整模考与学习统计:交卷、评分报告、练习历史、正确率趋势、错题复习计划。 3. 完整模考与学习统计:交卷、评分报告、练习历史、正确率趋势、错题复习计划。
4. 视频会员控制:视频资源签名 URL、防盗链、水印、播放次数和会员权益。 4. 视频会员控制:视频资源签名 URL、防盗链、水印、播放次数和会员权益。

View File

@@ -0,0 +1,158 @@
# 内容导入契约
更新时间2026-06-21
## 结论
内容导入的最终规范化、校验、租户隔离、幂等和审计必须由后端负责。
前端只负责:
- 上传或粘贴 JSON/Excel/CSV。
- 做轻量格式预检查,减少明显错误。
- 展示后端 preview 返回的 `job/items/issues`
- 让运营人员修正数据后再确认导入。
迁移脚本只负责:
- 从 PocketBase 导出或旧 JSON 中抽取数据。
- 转换成新架构推荐格式。
- 调用后端 preview/import API。
不建议迁移脚本直接绕过后端写业务表,除非是一次性内控迁移,并且必须额外跑导入后校验。
## 已实现导入 API
```text
POST /api/tenant-content/imports/preview/questions
POST /api/tenant-content/imports/questions
POST /api/tenant-content/imports/preview/vocabulary
POST /api/tenant-content/imports/vocabulary
POST /api/tenant-content/imports/preview/handbook
POST /api/tenant-content/imports/handbook
GET /api/tenant-content/imports
GET /api/tenant-content/imports/issues
```
所有导入都会写入:
- `content_import_jobs`
- `content_import_items`
- `content_import_issues`
- `audit_logs`
## 单词导入
推荐新格式:
```json
{
"regionId": "uuid",
"entryId": "uuid",
"contentNodeId": "uuid",
"units": [
{
"legacyId": "unit-1",
"name": "Unit 1 - 高频核心词",
"description": "专升本考试高频词汇",
"order": 1,
"words": [
{
"legacyId": "word-abandon",
"word": "abandon",
"phonetic": "/əˈbændən/",
"meaning": "v. 放弃,抛弃",
"example": "He had to abandon his car in the snow.",
"exampleTranslation": "他不得不把车丢弃在雪地里。",
"difficulty": 3,
"tags": ["高频词"],
"order": 1
}
]
}
]
}
```
兼容旧模板:
- `vocabulary_units_示例数据`
- `vocabulary_示例数据`
后端会把旧模板归一化为 `vocabulary_units/vocabulary_words`,并可自动挂到 `content_entries/content_nodes`
## 知识手册导入
推荐新格式:
```json
{
"regionId": "uuid",
"entryId": "uuid",
"contentNodeId": "uuid",
"subjects": [
{
"legacyId": "handbook-chinese",
"name": "大学语文",
"type": "guide",
"chapters": [
{
"legacyId": "chapter-outline",
"name": "一、语文考纲",
"sections": [
{
"legacyId": "section-outline",
"name": "考纲解读",
"entries": [
{
"legacyId": "entry-outline",
"title": "2024年天津专升本语文考试大纲",
"summary": "全面解读语文考试要求",
"content": "Markdown 内容",
"tags": ["考纲"]
}
]
}
]
}
]
}
]
}
```
映射规则:
- 手册入口:`content_entries.entry_type = handbook`
- 书籍/科目:`handbook_subjects`,并生成或绑定一个 `content_nodes`
- 章节:`handbook_chapters`,并生成章节节点
- 小节:默认进入 `content_nodes`,作为知识点的目录节点
- 知识点:`handbook_entries`,通过 `content_node_id` 归属到小节或章节
## 题目导入
题目继续兼容旧题库 JSON 数组格式,并支持 Markdown、KaTeX、图片、表格、阅读理解子题等字段。导入时可以传
- `subjectId`
- `categoryId`
- `entryId`
- `contentNodeId`
- `collectionId`
这样题目会同时落到旧兼容表和新内容导航/题目集合。
## 幂等规则
- 优先使用 `legacyId` 作为跨迁移稳定标识。
- 没有 `legacyId` 时,后端按导入 job 和行号生成内部标识。
- 相同 `legacyId` 再导入会更新。
- 内容 hash 未变化时标记为 `skipped`
## 下一步
- 增加 Excel/CSV 解析入口,但解析后仍进入同一套 preview/import 管线。
- 增加分数线、视频导入。
- 增加异步 worker处理大批量导入、重试和导入后校验。

View File

@@ -4,7 +4,7 @@
## 当前结论 ## 当前结论
当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目 JSON 批量导入基础闭环、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。 当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目/单词/知识手册 JSON 批量导入基础闭环、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。
但这还不是完整商用交付状态,也不能说旧项目核心功能已经全部重构完成。现在更准确的状态是:后端商用架构骨架已经立住,核心业务正在按模块补齐。部分功能已经有可调用 API部分功能只有数据模型和导入映射部分功能还没有前端/自动化测试闭环。 但这还不是完整商用交付状态,也不能说旧项目核心功能已经全部重构完成。现在更准确的状态是:后端商用架构骨架已经立住,核心业务正在按模块补齐。部分功能已经有可调用 API部分功能只有数据模型和导入映射部分功能还没有前端/自动化测试闭环。
@@ -28,8 +28,8 @@
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 | | 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 | | 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
| 用户订阅/题库会员/SVIP | 已建 `orders``payments``entitlements``svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、手动支付确认、激活码兑换、权益查询已实现 | 仅烟测 | 业务骨架可跑,真实微信/支付宝支付和 webhook 未完成 | | 用户订阅/题库会员/SVIP | 已建 `orders``payments``entitlements``svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、手动支付确认、激活码兑换、权益查询已实现 | 仅烟测 | 业务骨架可跑,真实微信/支付宝支付和 webhook 未完成 |
| 背单词 | 已建单词单元、单词、进度、收藏表 | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、租户后台单词维护 API 已实现 | 核心 API 集成测试 | 学生端基础学习状态后台单词维护已实现,复习算法和后台统计待完善 | | 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、租户后台单词维护 API、旧模板/新模板 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端基础学习状态后台维护和批量 JSON 导入已实现,复习算法和后台统计待完善 |
| 知识手册 | 已建手册科目、章节、条目 | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API 已实现 | 核心 API 集成测试 | 学生端阅读后台维护基础可用,富文本资源/版本管理待补 | | 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 |
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API 已实现 | 核心 API 集成测试 | 查询和后台维护基础闭环已实现,复杂动态筛选/批量导入待补 | | 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API 已实现 | 核心 API 集成测试 | 查询和后台维护基础闭环已实现,复杂动态筛选/批量导入待补 |
| 题目视频讲解 | 已建 `video_explanations``question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、租户后台视频创建绑定 API 已实现 | 核心 API 集成测试 | 播放数据和后台绑定链路已实现,会员权限、签名 URL、播放统计待补 | | 题目视频讲解 | 已建 `video_explanations``question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、租户后台视频创建绑定 API 已实现 | 核心 API 集成测试 | 播放数据和后台绑定链路已实现,会员权限、签名 URL、播放统计待补 |
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、上传/下载签名占位、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载 | 资料资源基础闭环可跑,真实 OSS/COS 签名、PDF 预览渲染、资料下载前端待补 | | 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、上传/下载签名占位、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载 | 资料资源基础闭环可跑,真实 OSS/COS 签名、PDF 预览渲染、资料下载前端待补 |
@@ -134,6 +134,10 @@ tenant-content:
POST /api/tenant-content/assets/sign-download POST /api/tenant-content/assets/sign-download
POST /api/tenant-content/imports/preview/questions POST /api/tenant-content/imports/preview/questions
POST /api/tenant-content/imports/questions POST /api/tenant-content/imports/questions
POST /api/tenant-content/imports/preview/vocabulary
POST /api/tenant-content/imports/vocabulary
POST /api/tenant-content/imports/preview/handbook
POST /api/tenant-content/imports/handbook
GET /api/tenant-content/imports GET /api/tenant-content/imports
GET /api/tenant-content/imports/issues GET /api/tenant-content/imports/issues
GET /api/tenant-content/videos GET /api/tenant-content/videos
@@ -237,7 +241,7 @@ platform-admin:
1. 正式鉴权:迁移期 `x-tenant-id``x-user-id``x-platform-admin-key` 要替换为 Supabase Auth/JWT/服务端 session并逐表验证 RLS。 1. 正式鉴权:迁移期 `x-tenant-id``x-user-id``x-platform-admin-key` 要替换为 Supabase Auth/JWT/服务端 session并逐表验证 RLS。
2. 国内能力接入短信、微信登录、微信小程序登录、QQ 登录、微信支付、支付宝支付的租户级配置入口已具备,但真实 provider adapter、回调验签和 webhook 幂等仍需实现。 2. 国内能力接入短信、微信登录、微信小程序登录、QQ 登录、微信支付、支付宝支付的租户级配置入口已具备,但真实 provider adapter、回调验签和 webhook 幂等仍需实现。
3. 核心缺口 API学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API下一步重点是后台维护、权限、统计和真实业务验收。 3. 核心缺口 API学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API下一步重点是后台维护、权限、统计和真实业务验收。
4. 后台能力题库录入、JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 APIExcel 导入、真实对象存储签名和前端操作台待补。 4. 后台能力:题库录入、题目/单词/知识手册 JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 APIExcel 导入、分数线/视频导入、真实对象存储签名和前端操作台待补。
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入集成测试;仍需真实数据导入回归、支付幂等、前端端到端测试。 5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入集成测试;仍需真实数据导入回归、支付幂等、前端端到端测试。
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。 6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。
7. 运维交付:生产环境变量、备份恢复、日志监控、异常告警、数据库迁移流程、灰度发布、回滚预案。 7. 运维交付:生产环境变量、备份恢复、日志监控、异常告警、数据库迁移流程、灰度发布、回滚预案。
@@ -246,7 +250,7 @@ platform-admin:
为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续: 为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续:
1. 完善内容导入和文件上传Excel/CSV、单词、手册、分数线、视频导入,接真实 OSS/COS/Supabase Storage 签名。 1. 完善内容导入和文件上传Excel/CSV、分数线、视频导入接真实 OSS/COS/Supabase Storage 签名,并把 JSON 导入扩展为异步 worker
2. 补地区/公共题库披露策略、租户套餐地区限制、主题模板系统。 2. 补地区/公共题库披露策略、租户套餐地区限制、主题模板系统。
3. 补学习统计:练习历史、正确率趋势、错题复习计划、单词复习算法。 3. 补学习统计:练习历史、正确率趋势、错题复习计划、单词复习算法。
4. 补视频商用控制SVIP 权限、签名 URL、防盗链、水印、播放次数扣减。 4. 补视频商用控制SVIP 权限、签名 URL、防盗链、水印、播放次数扣减。

View File

@@ -0,0 +1,170 @@
# 后续开发 TODO
更新时间2026-06-22
## 当前后端基线
后端已经完成可本地验证的商用 SaaS 骨架:
- Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。
- Node.js API 分层:`core/features`
- 学生端核心 API题库、练习、答题、错题、收藏、背单词、知识手册、分数线、视频、资料、订单、权益、个人中心。
- 租户后台 API品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理。
- 平台后台 API租户、SaaS 套餐、订阅、账单、服务费收款、用量。
- 销售/代理/CRM 增长链路邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
- 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。
- 本地验证:`npm run check:refactor` 已通过。
## 后端待补功能
### P0 上云测试前必须补齐
1. 生产鉴权
- 用 Supabase Auth/JWT 或服务端 session 替换迁移期 `x-tenant-id``x-user-id``x-platform-admin-key`
- 校验平台管理员、租户管理员、运营、教师、销售、代理、学生的访问边界。
- 做一轮真实 JWT + RLS 回归测试。
2. 对象存储
- 接阿里云 OSS、腾讯云 COS 或 Supabase Storage。
- 完成上传签名、下载签名、PDF 预览地址、视频播放签名。
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
3. 真实导入 dry-run
- 从 PocketBase 导出现有用户、题库、单词、知识手册、分数线、订单、权益数据。
-`scripts/import-pocketbase`,生成迁移报告。
- 对题目 JSON、单词、知识手册走后端 preview/import API 做二次验证。
4. 部署配置
- 整理生产 `.env` 模板。
- 确认数据库迁移流程、备份恢复、日志、告警。
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
### P1 商用功能完善
1. 支付
- 微信支付、支付宝、XPay 或实际使用的支付网关 adapter。
- webhook 验签、幂等、退款、支付补偿任务。
- 租户自有商户收款和平台代收/服务商模式。
2. 国内登录和短信
- 阿里云短信、腾讯云短信 adapter。
- 微信小程序登录、微信网页登录、QQ 登录。
- 旧 PocketBase 用户账号和新身份体系的映射/补绑。
3. 导入体系扩展
- Excel/CSV 导入。
- 分数线批量导入。
- 视频批量导入和题目视频批量绑定。
- 大批量导入异步 worker、重试、导入后校验。
4. 公共题库和租户授权
- 平台公共题库/地区题库。
- 按 SaaS 套餐限制地区、科目、题库范围。
- 租户采纳、复制、授权、版本同步策略。
5. 视频会员控制
- 视频 SVIP 权限、播放次数扣减。
- 防盗链、水印、播放日志、播放统计。
- 单题视频和通用知识视频混合推荐。
6. 学习统计
- 练习历史、正确率趋势、题型分布、错题复习计划。
- 单词复习算法、每日计划、排行榜。
- 模考交卷、评分报告、错题解析汇总。
7. 数据看板
- 收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量。
- 套餐销量、运营动态、24h 活跃度、激活码使用情况。
- 销售/代理转化、分佣结算、客资跟进效果。
8. AI 择校推荐
- 地区考试数据上下文。
- 学生输入 schema。
- AI 返回 JSON schema。
- 报告渲染和 PDF 生成。
### P2 运营体验和企业交付
1. 自定义角色
- 租户内角色模板。
- 菜单可见、模块可见、字段级权限。
- 权限变更审计。
2. 主题系统
- 平台默认三套主题。
- 租户自定义主色、Logo、图标、启动图、小程序分享图。
- 主题预览和发布。
3. CRM worker
- 钉钉、飞书、企业微信机器人 adapter。
- 轮询/定向分配。
- 推送失败重试和签名。
4. 运维
- 后台操作审计报表。
- 定时备份、恢复演练。
- 性能压测、慢 SQL、索引审查。
## Taro 前端开发 TODO
### 架构目标
- 建议新建 `apps/taro`,不要继续在旧 React Web 上堆兼容。
- H5 和小程序共用同一套业务 API client。
- 租户通过域名、小程序配置或启动参数解析。
- 页面主题、品牌、功能开关都从后端租户配置读取。
### 第一批页面
1. 租户启动与首页
-`/api/tenant/resolve`
- 读取品牌、主题、Banner、公告、功能开关
- 展示题库入口、背单词、知识手册、分数线、资料、会员
2. 登录
- 迁移期可先接短信 mock 或临时登录
- 生产接微信小程序登录、短信登录、QQ/微信网页登录
3. 题库
- `content_entries/content_nodes`
- `question_collections/practice_blueprints`
- 顺序刷题、随机刷题、全真模拟
- 答题、解析、错题、收藏、视频解析入口
4. 背单词
- 单元列表、单词列表
- 学习状态、收藏、统计
- 后续补复习算法
5. 知识手册
- 手册入口、章节、小节、知识点阅读
- Markdown/公式/图片基础渲染
6. 分数线
- 院校、专业、年份、动态字段筛选
- 趋势图
7. 资料下载
- PDF 列表、权限提示、预览、下载
8. 个人中心
- 会员权益、订单、激活码兑换
- 错题本、收藏夹、学习统计
### 前端接入原则
- 不在前端实现最终权限判断,前端只做 UI 可见性控制。
- 不在前端直接拼接私有资源 URL统一请求后端签名。
- 不把旧 PocketBase 字段当成长期模型,优先使用新 API 返回的 `entryId/contentNodeId/collectionId/blueprintId`
- 小程序码、支付、登录等平台能力统一走 provider adapter不在页面里硬编码租户密钥。
## 推荐下一步顺序
1. 先把当前后端代码推到 Gitea。
2. 云服务器部署 Supabase/PostgreSQL 和 API`check:refactor` 的远程等价测试。
3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。
5. 并行补对象存储、真实登录、支付 adapter。
6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。

View File

@@ -802,6 +802,231 @@ async function testTenantContentAssetsAndImports() {
'catalog should expose imported question through the new collection binding', 'catalog should expose imported question through the new collection binding',
); );
const vocabEntry = await request('/api/tenant-content/content-entries', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryKey: 'integration-vocabulary',
regionId: ids.region,
name: '集成测试背单词入口',
entryType: 'vocabulary',
route: '/vocabulary',
order: 21,
},
});
const vocabRoot = await request('/api/tenant-content/content-nodes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: vocabEntry.item.id,
regionId: ids.region,
nodeKey: 'integration-vocabulary-root',
name: '英语核心词',
nodeType: 'category',
isLeaf: false,
},
});
const deniedVocabularyPreview = await request('/api/tenant-content/imports/preview/vocabulary', {
method: 'POST',
body: {
units: [{ name: '学生不能导入单词', words: [{ word: 'deny', meaning: '拒绝' }] }],
},
expectStatus: 403,
});
assert.equal(deniedVocabularyPreview.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not preview vocabulary import');
const invalidVocabularyPreview = await request('/api/tenant-content/imports/preview/vocabulary', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'invalid-vocabulary-import.json',
entryId: vocabEntry.item.id,
contentNodeId: vocabRoot.item.id,
vocabulary_units_示例数据: [
{ legacyId: 'integration-vocab-invalid-unit', name: '旧格式错误单词单元', order: 1 },
],
vocabulary_示例数据: [
{ unitId: 'integration-vocab-invalid-unit', word: '', meaning: '', order: 1 },
],
},
});
assert.ok(invalidVocabularyPreview.issues?.some(issue => issue.code === 'VOCABULARY_WORD_REQUIRED'), 'invalid vocabulary preview should validate word');
assert.ok(invalidVocabularyPreview.issues?.some(issue => issue.code === 'VOCABULARY_MEANING_REQUIRED'), 'invalid vocabulary preview should validate meaning');
const rejectedVocabularyImport = await request('/api/tenant-content/imports/vocabulary', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: invalidVocabularyPreview.job.id },
expectStatus: 409,
});
assert.equal(rejectedVocabularyImport.code, 'IMPORT_HAS_ERRORS', 'invalid vocabulary import should be rejected');
const vocabularyPreview = await request('/api/tenant-content/imports/preview/vocabulary', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'legacy-vocabulary-import.json',
regionId: ids.region,
entryId: vocabEntry.item.id,
contentNodeId: vocabRoot.item.id,
vocabulary_units_示例数据: [
{
legacyId: 'integration-vocab-unit-001',
name: 'Unit 1 - 高频核心词',
description: '旧模板字段导入验证',
order: 1,
isActive: true,
},
],
vocabulary_示例数据: [
{
legacyId: 'integration-vocab-word-abandon',
unitId: 'integration-vocab-unit-001',
word: 'abandon',
phonetic: '/əˈbændən/',
meaning: 'v. 放弃,抛弃',
example: 'He had to abandon his car in the snow.',
exampleTranslation: '他不得不把车丢弃在雪地里。',
difficulty: 3,
tags: ['高频词', '考纲核心'],
order: 1,
isActive: true,
},
],
},
});
assert.equal(vocabularyPreview.job?.errorCount, 0, 'valid vocabulary preview should have no errors');
const vocabularyImport = await request('/api/tenant-content/imports/vocabulary', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: vocabularyPreview.job.id },
});
assert.equal(vocabularyImport.item?.status, 'completed', 'valid vocabulary import should complete');
assert.ok(
(vocabularyImport.item?.insertedCount || 0) + (vocabularyImport.item?.updatedCount || 0) + (vocabularyImport.item?.skippedCount || 0) >= 2,
'vocabulary import should process unit and word idempotently',
);
const vocabularyUnits = await request('/api/catalog/vocabulary-units', {
query: { regionId: ids.region },
});
const importedVocabularyUnit = vocabularyUnits.items?.find(item => item.legacyId === 'integration-vocab-unit-001');
assert.ok(importedVocabularyUnit, 'catalog should expose imported vocabulary unit');
assert.equal(importedVocabularyUnit.entryId, vocabEntry.item.id, 'vocabulary unit should bind content entry');
const vocabularyWords = await request('/api/catalog/vocabulary-words', {
query: { unitId: importedVocabularyUnit.id },
});
assert.ok(vocabularyWords.items?.some(item => item.word === 'abandon' && item.contentNodeId), 'catalog should expose imported vocabulary word with node binding');
const handbookEntry = await request('/api/tenant-content/content-entries', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryKey: 'integration-handbook',
regionId: ids.region,
name: '集成测试知识手册入口',
entryType: 'handbook',
route: '/handbook',
order: 22,
},
});
const handbookRoot = await request('/api/tenant-content/content-nodes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: handbookEntry.item.id,
regionId: ids.region,
nodeKey: 'integration-handbook-root',
name: '文化课',
nodeType: 'category',
isLeaf: false,
},
});
const handbookPreview = await request('/api/tenant-content/imports/preview/handbook', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
sourceName: 'handbook-nested-import.json',
regionId: ids.region,
entryId: handbookEntry.item.id,
contentNodeId: handbookRoot.item.id,
subjects: [
{
legacyId: 'integration-handbook-chinese',
name: '大学语文',
type: 'guide',
icon: 'book-open',
color: '#10B981',
chapters: [
{
legacyId: 'integration-handbook-chapter-outline',
name: '一、语文考纲',
sections: [
{
legacyId: 'integration-handbook-section-outline',
name: '考纲解读',
entries: [
{
legacyId: 'integration-handbook-entry-outline',
title: '2024年天津专升本语文考试大纲',
summary: '全面解读语文考试要求和考点分布',
content: '一、考试性质\n\n天津市高职升本科招生统一考试是选拔性考试。',
tags: ['考纲', '必读'],
order: 1,
},
],
},
],
},
],
},
],
},
});
assert.equal(handbookPreview.job?.errorCount, 0, 'valid handbook preview should have no errors');
const handbookImport = await request('/api/tenant-content/imports/handbook', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { previewJobId: handbookPreview.job.id },
});
assert.equal(handbookImport.item?.status, 'completed', 'valid handbook import should complete');
assert.ok(
(handbookImport.item?.insertedCount || 0) + (handbookImport.item?.updatedCount || 0) + (handbookImport.item?.skippedCount || 0) >= 3,
'handbook import should process subject, chapter, and entry idempotently',
);
const handbookSubjects = await request('/api/catalog/handbook-subjects', {
query: { regionId: ids.region },
});
const importedHandbookSubject = handbookSubjects.items?.find(item => item.legacyId === 'integration-handbook-chinese');
assert.ok(importedHandbookSubject, 'catalog should expose imported handbook subject');
assert.equal(importedHandbookSubject.entryId, handbookEntry.item.id, 'handbook subject should bind content entry');
const handbookChapters = await request('/api/catalog/handbook-chapters', {
query: { subjectId: importedHandbookSubject.id },
});
const importedHandbookChapter = handbookChapters.items?.find(item => item.legacyId === 'integration-handbook-chapter-outline');
assert.ok(importedHandbookChapter, 'catalog should expose imported handbook chapter');
const handbookEntries = await request('/api/catalog/handbook-entries', {
query: { chapterId: importedHandbookChapter.id, includeContent: true },
});
assert.ok(
handbookEntries.items?.some(item => item.legacyId === 'integration-handbook-entry-outline' && item.content?.includes('选拔性考试')),
'catalog should expose imported handbook entry content',
);
const importJobs = await request('/api/tenant-content/imports', {
userId: TENANT_ADMIN_USER_ID,
query: { importType: 'vocabulary', limit: 10 },
});
assert.ok(importJobs.items?.some(item => item.id === vocabularyPreview.job.id && item.status === 'completed'), 'import job list should include completed vocabulary job');
const partnerImports = await request('/api/tenant-content/imports', { const partnerImports = await request('/api/tenant-content/imports', {
tenantId: PARTNER_TENANT_ID, tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID, userId: TENANT_ADMIN_USER_ID,

View File

@@ -0,0 +1,39 @@
alter table public.vocabulary_units
add column if not exists entry_id uuid references public.content_entries(id) on delete set null,
add column if not exists content_node_id uuid references public.content_nodes(id) on delete set null,
add column if not exists source_hash text,
add column if not exists metadata jsonb not null default '{}'::jsonb;
alter table public.vocabulary_words
add column if not exists entry_id uuid references public.content_entries(id) on delete set null,
add column if not exists content_node_id uuid references public.content_nodes(id) on delete set null,
add column if not exists source_hash text,
add column if not exists metadata jsonb not null default '{}'::jsonb;
alter table public.handbook_subjects
add column if not exists entry_id uuid references public.content_entries(id) on delete set null,
add column if not exists content_node_id uuid references public.content_nodes(id) on delete set null,
add column if not exists source_hash text;
alter table public.handbook_chapters
add column if not exists entry_id uuid references public.content_entries(id) on delete set null,
add column if not exists content_node_id uuid references public.content_nodes(id) on delete set null,
add column if not exists source_hash text,
add column if not exists metadata jsonb not null default '{}'::jsonb;
alter table public.handbook_entries
add column if not exists entry_id uuid references public.content_entries(id) on delete set null,
add column if not exists content_node_id uuid references public.content_nodes(id) on delete set null,
add column if not exists source_hash text,
add column if not exists metadata jsonb not null default '{}'::jsonb;
create index if not exists idx_vocabulary_units_navigation
on public.vocabulary_units(tenant_id, entry_id, content_node_id, region_id, is_active, sort_order);
create index if not exists idx_vocabulary_words_navigation
on public.vocabulary_words(tenant_id, entry_id, content_node_id, unit_id, is_active, sort_order);
create index if not exists idx_handbook_subjects_navigation
on public.handbook_subjects(tenant_id, entry_id, content_node_id, region_id, is_active, sort_order);
create index if not exists idx_handbook_chapters_navigation
on public.handbook_chapters(tenant_id, entry_id, content_node_id, subject_id, is_active, sort_order);
create index if not exists idx_handbook_entries_navigation
on public.handbook_entries(tenant_id, entry_id, content_node_id, chapter_id, is_active, sort_order);