forked from wangziqi/gongxue-base
feat: add vocabulary handbook import pipeline
This commit is contained in:
10
README.md
10
README.md
@@ -13,7 +13,7 @@
|
||||
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
|
||||
- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用。
|
||||
- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、审计日志。
|
||||
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目 JSON 批量导入。
|
||||
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册 JSON 批量导入。
|
||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。
|
||||
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。
|
||||
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
|
||||
@@ -25,13 +25,15 @@
|
||||
- 正式 Supabase Auth/JWT 鉴权还没替换迁移期请求头。
|
||||
- 真实短信、微信登录、QQ 登录、微信支付、支付宝等 provider adapter 还没接完。
|
||||
- 真实 OSS/COS/Supabase Storage 上传下载签名还没接完。
|
||||
- Excel/CSV 导入、单词/手册/分数线/视频批量导入和异步 worker 还没完成。
|
||||
- Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。
|
||||
- Taro 跨端前端还没开始 scaffold。
|
||||
|
||||
更完整的进度看这些文档:
|
||||
|
||||
- `docs/refactor/implementation-status.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/api-structure.md`
|
||||
|
||||
@@ -142,7 +144,7 @@ apps/api/src/features/
|
||||
- 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。
|
||||
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL。
|
||||
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
||||
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。
|
||||
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册导入已走这套后台校验管线,前端只做预检查和预览展示。
|
||||
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用。
|
||||
|
||||
## 最近一次验证
|
||||
@@ -162,7 +164,7 @@ npm run test:api
|
||||
优先继续补:
|
||||
|
||||
1. 真实对象存储 adapter:阿里云 OSS、腾讯云 COS 或 Supabase Storage。
|
||||
2. Excel/CSV 以及单词、知识手册、分数线、视频批量导入。
|
||||
2. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。
|
||||
3. Supabase Auth/JWT 正式鉴权和生产 RLS 验证。
|
||||
4. 微信/QQ 登录、短信、微信支付、支付宝支付 adapter。
|
||||
5. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。
|
||||
|
||||
@@ -321,6 +321,7 @@ export async function vocabularyUnitsRoute(ctx: RequestContext) {
|
||||
const items = await query(
|
||||
`
|
||||
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",
|
||||
sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
@@ -349,6 +350,7 @@ export async function vocabularyWordsRoute(ctx: RequestContext) {
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", 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",
|
||||
@@ -378,6 +380,7 @@ export async function handbookSubjectsRoute(ctx: RequestContext) {
|
||||
const items = await query(
|
||||
`
|
||||
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",
|
||||
is_active as "isActive", metadata,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
@@ -404,6 +407,7 @@ export async function handbookChaptersRoute(ctx: RequestContext) {
|
||||
const items = await query(
|
||||
`
|
||||
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",
|
||||
is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
@@ -431,6 +435,7 @@ export async function handbookEntriesRoute(ctx: RequestContext) {
|
||||
const items = await query(
|
||||
`
|
||||
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'},
|
||||
tags, sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,14 @@ import {
|
||||
upsertAssetRoute,
|
||||
} from './assets.js';
|
||||
import {
|
||||
importHandbookRoute,
|
||||
importIssuesRoute,
|
||||
importJobsRoute,
|
||||
importQuestionsRoute,
|
||||
importVocabularyRoute,
|
||||
previewHandbookImportRoute,
|
||||
previewQuestionsImportRoute,
|
||||
previewVocabularyImportRoute,
|
||||
} from './imports.js';
|
||||
import {
|
||||
contentEntriesAdminRoute,
|
||||
@@ -66,6 +70,10 @@ export const tenantContentRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/tenant-content/assets/sign-download', signAssetDownloadAdminRoute],
|
||||
['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute],
|
||||
['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/issues', importIssuesRoute],
|
||||
['GET', '/api/tenant-content/videos', videosAdminRoute],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import type pg from 'pg';
|
||||
import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.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(
|
||||
client: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> },
|
||||
tenantId: string,
|
||||
@@ -652,7 +688,8 @@ export async function vocabularyUnitsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
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"
|
||||
from public.vocabulary_units
|
||||
where tenant_id = $1
|
||||
@@ -666,35 +703,53 @@ export async function vocabularyUnitsAdminRoute(ctx: RequestContext) {
|
||||
export async function upsertVocabularyUnitRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.vocabulary_units (
|
||||
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),
|
||||
const item = await transaction(async client => {
|
||||
const navigation = await resolveOptionalContentNavigation(
|
||||
client,
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
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),
|
||||
],
|
||||
);
|
||||
nullableString(body.entryId),
|
||||
nullableString(body.contentNodeId),
|
||||
'vocabulary',
|
||||
);
|
||||
const result = await client.query(
|
||||
`
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -704,7 +759,8 @@ export async function vocabularyWordsAdminRoute(ctx: RequestContext) {
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
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,
|
||||
sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.vocabulary_words
|
||||
@@ -720,46 +776,76 @@ export async function vocabularyWordsAdminRoute(ctx: RequestContext) {
|
||||
export async function upsertVocabularyWordRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.vocabulary_words (
|
||||
id, tenant_id, unit_id, legacy_id, word, phonetic, meaning,
|
||||
example, example_translation, difficulty, tags, sort_order, is_active
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13)
|
||||
on conflict (id)
|
||||
do update set unit_id = excluded.unit_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,
|
||||
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),
|
||||
const item = await transaction(async client => {
|
||||
const unitId = nullableString(body.unitId);
|
||||
let inheritedEntryId: string | null = null;
|
||||
let inheritedContentNodeId: string | null = null;
|
||||
if (unitId) {
|
||||
const unit = await client.query<{ id: string; entry_id: string | null; content_node_id: string | null }>(
|
||||
'select id, entry_id, content_node_id from public.vocabulary_units where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, unitId],
|
||||
);
|
||||
if (!unit.rows[0]) throw new HttpError(400, 'unitId is not in this tenant', 'VOCABULARY_UNIT_NOT_FOUND');
|
||||
inheritedEntryId = unit.rows[0].entry_id;
|
||||
inheritedContentNodeId = unit.rows[0].content_node_id;
|
||||
}
|
||||
const navigation = await resolveOptionalContentNavigation(
|
||||
client,
|
||||
auth.tenantId,
|
||||
nullableString(body.unitId),
|
||||
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),
|
||||
],
|
||||
);
|
||||
nullableString(body.entryId) || inheritedEntryId,
|
||||
nullableString(body.contentNodeId) || inheritedContentNodeId,
|
||||
'vocabulary',
|
||||
);
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.vocabulary_words (
|
||||
id, tenant_id, unit_id, entry_id, content_node_id, legacy_id, word,
|
||||
phonetic, meaning, example, example_translation, difficulty, 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, $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 };
|
||||
}
|
||||
|
||||
@@ -767,7 +853,8 @@ export async function handbookSubjectsAdminRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
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
|
||||
from public.handbook_subjects
|
||||
where tenant_id = $1
|
||||
@@ -781,43 +868,58 @@ export async function handbookSubjectsAdminRoute(ctx: RequestContext) {
|
||||
export async function upsertHandbookSubjectRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.handbook_subjects (
|
||||
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),
|
||||
const item = await transaction(async client => {
|
||||
const navigation = await resolveOptionalContentNavigation(
|
||||
client,
|
||||
auth.tenantId,
|
||||
nullableString(body.regionId),
|
||||
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),
|
||||
],
|
||||
);
|
||||
nullableString(body.entryId),
|
||||
nullableString(body.contentNodeId),
|
||||
'handbook',
|
||||
);
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.handbook_subjects (
|
||||
id, tenant_id, region_id, entry_id, content_node_id, legacy_id,
|
||||
name, type, icon, color, 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, $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 };
|
||||
}
|
||||
|
||||
@@ -826,7 +928,8 @@ export async function handbookChaptersAdminRoute(ctx: RequestContext) {
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
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"
|
||||
from public.handbook_chapters
|
||||
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) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.handbook_chapters (
|
||||
id, tenant_id, subject_id, legacy_id, name, description, sort_order, is_active
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8)
|
||||
on conflict (id)
|
||||
do update set subject_id = excluded.subject_id,
|
||||
name = excluded.name,
|
||||
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),
|
||||
const item = await transaction(async client => {
|
||||
const subjectId = requiredString(body, 'subjectId');
|
||||
const subject = await client.query<{ id: string; entry_id: string | null; content_node_id: string | null }>(
|
||||
'select id, entry_id, content_node_id from public.handbook_subjects where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, subjectId],
|
||||
);
|
||||
if (!subject.rows[0]) throw new HttpError(400, 'subjectId is not in this tenant', 'HANDBOOK_SUBJECT_NOT_FOUND');
|
||||
const navigation = await resolveOptionalContentNavigation(
|
||||
client,
|
||||
auth.tenantId,
|
||||
requiredString(body, 'subjectId'),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'name'),
|
||||
nullableString(body.description),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
nullableString(body.entryId) || subject.rows[0].entry_id,
|
||||
nullableString(body.contentNodeId) || subject.rows[0].content_node_id,
|
||||
'handbook',
|
||||
);
|
||||
const result = await client.query(
|
||||
`
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -875,7 +1002,8 @@ export async function handbookEntriesAdminRoute(ctx: RequestContext) {
|
||||
const chapterId = stringParam(ctx, 'chapterId');
|
||||
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"
|
||||
from public.handbook_entries
|
||||
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) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const item = await queryOne(
|
||||
`
|
||||
insert into public.handbook_entries (
|
||||
id, tenant_id, chapter_id, legacy_id, title, summary, content,
|
||||
tags, sort_order, is_active
|
||||
)
|
||||
values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8::jsonb, $9, $10)
|
||||
on conflict (id)
|
||||
do update set chapter_id = excluded.chapter_id,
|
||||
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),
|
||||
const item = await transaction(async client => {
|
||||
const chapterId = requiredString(body, 'chapterId');
|
||||
const chapter = await client.query<{ id: string; entry_id: string | null; content_node_id: string | null }>(
|
||||
'select id, entry_id, content_node_id from public.handbook_chapters where tenant_id = $1 and id = $2 limit 1',
|
||||
[auth.tenantId, chapterId],
|
||||
);
|
||||
if (!chapter.rows[0]) throw new HttpError(400, 'chapterId is not in this tenant', 'HANDBOOK_CHAPTER_NOT_FOUND');
|
||||
const navigation = await resolveOptionalContentNavigation(
|
||||
client,
|
||||
auth.tenantId,
|
||||
requiredString(body, 'chapterId'),
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'title'),
|
||||
nullableString(body.summary),
|
||||
nullableString(body.content),
|
||||
jsonArrayValue(body.tags),
|
||||
intValue(body.order, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
nullableString(body.entryId) || chapter.rows[0].entry_id,
|
||||
nullableString(body.contentNodeId) || chapter.rows[0].content_node_id,
|
||||
'handbook',
|
||||
);
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.handbook_entries (
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -18,11 +18,13 @@
|
||||
- `scripts/import-pocketbase`:PocketBase schema/数据导入工具。
|
||||
- `docker-compose.api.yml`、`apps/api/Dockerfile`:本地 Docker API 运行入口。
|
||||
- `docs/refactor/architecture.md`:新重构目录边界和工程规范。
|
||||
- `docs/refactor/content-import-contract.md`:题目、单词、知识手册导入契约,明确后端校验、旧格式转换和前端职责。
|
||||
- `docs/refactor/next-development-todo.md`:后端剩余缺口、Taro 前端接入顺序、上云测试前待办。
|
||||
|
||||
下一步优先级:
|
||||
|
||||
1. 导出 PocketBase 真实数据到 `pb_export/*.json`。
|
||||
2. 执行 `npm run pb:import:json` 和 `npm run pb:import:validate`。
|
||||
3. 按学生端页面逐步从 PocketBase SDK 切换到 `src/services/supabaseApi.ts`。
|
||||
4. 为订单、支付、权益开通补齐 API 写入流程和 webhook 幂等处理。
|
||||
4. 为分数线、视频、Excel/CSV 补齐批量导入,并复用 `content_import_jobs` 管线。
|
||||
5. 新建 Taro 学生端时复用同一套租户解析和业务 API,不另起一套后端。
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列。
|
||||
- `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。
|
||||
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。
|
||||
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目 JSON 导入维护。
|
||||
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。
|
||||
- `tenant`:域名/租户解析。
|
||||
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
|
||||
- 已新增 `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/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
|
||||
PUT /api/tenant-content/videos
|
||||
@@ -173,11 +177,11 @@ GET /api/tenant-admin/audit-logs
|
||||
- CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。
|
||||
- 内容资源当前完成台账、租户后台维护、上传/下载签名占位和学生端 SVIP 下载权限;真实对象存储签名、PDF 预览渲染和防盗链在 provider/worker 中实现。
|
||||
- 题库内容导航当前以 `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。
|
||||
3. 接入真实 OAuth provider:微信网页、微信小程序、QQ,并处理旧 PocketBase 身份映射。
|
||||
4. 增加真实支付 provider:XPay、微信支付、支付宝,并完善 webhook 幂等。
|
||||
|
||||
@@ -18,10 +18,10 @@
|
||||
| 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略、地区/全国套餐权限、平台侧主题模板库、平台审计 |
|
||||
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
|
||||
| 租户成员权限 | 基础完成 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,审计查询 | 前端权限 UI、自定义角色模板、菜单级可见配置 |
|
||||
| 题库内容维护 | 基础完成 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目 JSON 预览/导入、视频绑定、分数线、单词、知识手册后台 API | Excel/CSV 批量导入、公题库采纳/复制/授权、可视化拖拽排序前端 |
|
||||
| 题库内容维护 | 基础完成 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目 JSON 预览/导入、视频绑定、分数线、单词、知识手册后台 API | Excel/CSV 批量导入、分数线/视频导入、公题库采纳/复制/授权、可视化拖拽排序前端 |
|
||||
| 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹 | 完整模考交卷评分报告、专项练习策略、错题复习计划、题型统计深度分析 |
|
||||
| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计 | 复习算法、每日计划、排行榜 |
|
||||
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||
| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、旧模板/新模板 JSON 预览导入、内容导航绑定 | 复习算法、每日计划、排行榜、Excel 导入 |
|
||||
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 |
|
||||
| 分数线 | 基础完成 | 字段、院校、专业、记录、趋势、年份 | 复杂动态筛选、批量导入、AI 择校数据上下文 |
|
||||
| 视频解析会员 | 部分完成 | 题目视频、批量查询、后台绑定 | SVIP 权限、播放次数扣减、签名 URL、防盗链、水印、播放统计 |
|
||||
| 资料下载/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 套餐限制地区。
|
||||
3. 完整模考与学习统计:交卷、评分报告、练习历史、正确率趋势、错题复习计划。
|
||||
4. 视频会员控制:视频资源签名 URL、防盗链、水印、播放次数和会员权益。
|
||||
|
||||
158
docs/refactor/content-import-contract.md
Normal file
158
docs/refactor/content-import-contract.md
Normal 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,处理大批量导入、重试和导入后校验。
|
||||
@@ -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,部分功能只有数据模型和导入映射,部分功能还没有前端/自动化测试闭环。
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
|
||||
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
|
||||
| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、手动支付确认、激活码兑换、权益查询已实现 | 仅烟测 | 业务骨架可跑,真实微信/支付宝支付和 webhook 未完成 |
|
||||
| 背单词 | 已建单词单元、单词、进度、收藏表 | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、租户后台单词维护 API 已实现 | 核心 API 集成测试 | 学生端基础学习状态和后台单词维护已实现,复习算法和后台统计待完善 |
|
||||
| 知识手册 | 已建手册科目、章节、条目 | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API 已实现 | 核心 API 集成测试 | 学生端阅读和后台维护基础可用,富文本资源/版本管理待补 |
|
||||
| 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、租户后台单词维护 API、旧模板/新模板 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端基础学习状态、后台维护和批量 JSON 导入已实现,复习算法和后台统计待完善 |
|
||||
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 学生端阅读、后台维护和批量 JSON 导入基础可用,富文本资源/版本管理待补 |
|
||||
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API 已实现 | 核心 API 集成测试 | 查询和后台维护基础闭环已实现,复杂动态筛选/批量导入待补 |
|
||||
| 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、租户后台视频创建绑定 API 已实现 | 核心 API 集成测试 | 播放数据和后台绑定链路已实现,会员权限、签名 URL、播放统计待补 |
|
||||
| 资料下载/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/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
|
||||
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。
|
||||
2. 国内能力接入:短信、微信登录、微信小程序登录、QQ 登录、微信支付、支付宝支付的租户级配置入口已具备,但真实 provider adapter、回调验签和 webhook 幂等仍需实现。
|
||||
3. 核心缺口 API:学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API;下一步重点是后台维护、权限、统计和真实业务验收。
|
||||
4. 后台能力:题库录入、JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;Excel 导入、真实对象存储签名和前端操作台待补。
|
||||
4. 后台能力:题库录入、题目/单词/知识手册 JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;Excel 导入、分数线/视频导入、真实对象存储签名和前端操作台待补。
|
||||
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入集成测试;仍需真实数据导入回归、支付幂等、前端端到端测试。
|
||||
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。
|
||||
7. 运维交付:生产环境变量、备份恢复、日志监控、异常告警、数据库迁移流程、灰度发布、回滚预案。
|
||||
@@ -246,7 +250,7 @@ platform-admin:
|
||||
|
||||
为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续:
|
||||
|
||||
1. 完善内容导入和文件上传:Excel/CSV、单词、手册、分数线、视频导入,接真实 OSS/COS/Supabase Storage 签名。
|
||||
1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,接真实 OSS/COS/Supabase Storage 签名,并把 JSON 导入扩展为异步 worker。
|
||||
2. 补地区/公共题库披露策略、租户套餐地区限制、主题模板系统。
|
||||
3. 补学习统计:练习历史、正确率趋势、错题复习计划、单词复习算法。
|
||||
4. 补视频商用控制:SVIP 权限、签名 URL、防盗链、水印、播放次数扣减。
|
||||
|
||||
170
docs/refactor/next-development-todo.md
Normal file
170
docs/refactor/next-development-todo.md
Normal 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. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
@@ -802,6 +802,231 @@ async function testTenantContentAssetsAndImports() {
|
||||
'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', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user