diff --git a/README.md b/README.md index 6d7aea36..9621d504 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 - `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 - 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 -- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册 JSON 批量导入。 +- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。 @@ -28,7 +28,7 @@ - Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。 - 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配;微信网页登录、QQ 登录、手机号换绑、完整资金流水对账和真实生产账号联调还没接完。 - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览和资源复检 worker 已完成,CDN 防盗链、杀毒扫描和视频动态水印还没完成。 -- Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。 +- Excel/CSV 导入和大批量异步导入 worker 还没完成;分数线/视频 JSON 批量导入已可联调。 - 分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。 - Taro 跨端前端还没开始 scaffold。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 @@ -186,7 +186,7 @@ API 身份上下文: - 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。 - 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;生产环境应定时运行 assets worker 复检对象元数据,异常资源会被标记 failed 并退回 draft。 - 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。 -- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册导入已走这套后台校验管线,前端只做预检查和预览展示。 +- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON 导入已走这套后台校验管线,前端只做预检查和预览展示。 - 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款。 ## 最近一次验证 @@ -206,5 +206,5 @@ npm run check:refactor 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。 3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。 -4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。 +4. Excel/CSV 导入;把现有 JSON 导入升级为可排队异步执行。 5. 微信网页/QQ 登录、完整资金流水对账、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/tenant-content/imports.ts b/apps/api/src/features/tenant-content/imports.ts index fb1980b1..988bc833 100644 --- a/apps/api/src/features/tenant-content/imports.ts +++ b/apps/api/src/features/tenant-content/imports.ts @@ -113,6 +113,74 @@ interface NormalizedHandbookSubject { metadata: JsonObject; } +type ContentImportType = 'vocabulary' | 'handbook' | 'scoreline' | 'videos'; +type EntryBoundImportType = 'vocabulary' | 'handbook'; +type GenericTargetEntryType = EntryBoundImportType | null; + +type ScorelineImportKind = 'field' | 'school' | 'major' | 'record'; + +interface NormalizedScorelineItem { + kind: ScorelineImportKind; + legacyId: string | null; + regionId: string | null; + sourceHash: string; + metadata: JsonObject; + fieldKey?: string; + fieldName?: string; + fieldType?: string | null; + unit?: string | null; + isFilter?: boolean; + isRequired?: boolean; + isVisible?: boolean; + isTrend?: boolean; + options?: string[]; + placeholder?: string | null; + description?: string | null; + schoolId?: string | null; + schoolLegacyId?: string | null; + schoolName?: string | null; + schoolShortName?: string | null; + schoolType?: string | null; + isHot?: boolean; + majorId?: string | null; + majorLegacyId?: string | null; + majorName?: string | null; + order?: number; + hasRestriction?: boolean; + restrictionDesc?: string | null; + year?: number; + fieldValues?: JsonObject; +} + +interface NormalizedVideoBinding { + legacyId: string | null; + questionId: string | null; + legacyQuestionId: string | null; + videoType: string; + order: number; +} + +interface NormalizedVideoImportItem { + legacyId: string | null; + title: string; + description: string | null; + videoUrl: string | null; + thumbnailUrl: string | null; + duration: number | null; + knowledgeTags: string[]; + isGeneral: boolean; + subjectId: string | null; + difficulty: number | null; + order: number; + isActive: boolean; + assetId: string | null; + accessMode: 'free' | 'svip' | 'video_quota'; + freePreviewSeconds: number; + bindings: NormalizedVideoBinding[]; + sourceHash: string; + metadata: JsonObject; +} + interface PreviewResult { job: { id: string; @@ -1191,11 +1259,362 @@ function createHandbookNormalizedItems(body: JsonObject): NormalizedImportItem((found, key) => found ?? body[key], undefined) ?? body.payload; + const source = parseJsonMaybe(rawSource ?? body, code); + const payload = objectValue(source); + const items = Array.isArray(source) + ? source + : keys.reduce((found, key) => (found.length ? found : asArray(payload[key])), []); + if (items.length === 0) { + throw new HttpError(400, `${label} import payload must contain at least one item`, 'EMPTY_IMPORT_PAYLOAD'); + } + if (items.length > maxItems) { + throw new HttpError(400, `A single ${label} import job can contain at most ${maxItems} items`, 'IMPORT_TOO_LARGE'); + } + return items; +} + +function uuidLike(value: string | null) { + return Boolean(value && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)); +} + +function nullableUuidValue(value: unknown, issues: ImportIssue[], rowNo: number, fieldPath: string) { + const normalized = stringValue(value) || null; + if (normalized && !uuidLike(normalized)) { + issues.push({ + rowNo, + severity: 'error', + code: 'INVALID_UUID', + fieldPath, + message: `${fieldPath} must be a valid UUID`, + }); + } + return normalized; +} + +function normalizeSortOrder(value: unknown, fallback: number) { + return integerValue(value, fallback); +} + +function normalizeScorelineKind(value: unknown): ScorelineImportKind | null { + const normalized = stringValue(value).toLowerCase().replace(/[-\s]/g, '_'); + if (['field', 'fields', 'scoreline_field', 'scoreline_fields'].includes(normalized)) return 'field'; + if (['school', 'schools', 'scoreline_school', 'scoreline_schools'].includes(normalized)) return 'school'; + if (['major', 'majors', 'scoreline_major', 'scoreline_majors'].includes(normalized)) return 'major'; + if (['record', 'records', 'scoreline_record', 'scoreline_records'].includes(normalized)) return 'record'; + return null; +} + +function scorelineFieldValues(source: JsonObject) { + const explicit = objectValue(source.fieldValues ?? source.field_values ?? source.values); + const reserved = new Set([ + 'id', + 'legacyId', + 'legacy_id', + 'externalId', + 'external_id', + 'kind', + 'type', + 'regionId', + 'region_id', + 'schoolId', + 'school_id', + 'schoolLegacyId', + 'school_legacy_id', + 'schoolName', + 'school_name', + 'majorId', + 'major_id', + 'majorLegacyId', + 'major_legacy_id', + 'majorName', + 'major_name', + 'year', + 'fieldValues', + 'field_values', + 'values', + 'metadata', + ]); + const inferred: JsonObject = {}; + for (const [key, value] of Object.entries(source)) { + if (!reserved.has(key) && value !== undefined && value !== null && value !== '') inferred[key] = value as unknown; + } + return Object.keys(explicit).length ? explicit : inferred; +} + +function normalizeScorelineItem(raw: unknown, rowNo: number, defaultRegionId: string | null) { + const issues: ImportIssue[] = []; + const source = objectValue(raw); + const kind = normalizeScorelineKind(source.kind ?? source.itemType ?? source.item_type ?? source.type); + if (!kind) { + issues.push({ + rowNo, + severity: 'error', + code: 'SCORELINE_KIND_REQUIRED', + fieldPath: 'kind', + message: 'scoreline item kind must be one of field, school, major, record', + }); + } + + const regionId = nullableUuidValue(source.regionId ?? source.region_id ?? defaultRegionId, issues, rowNo, 'regionId'); + const legacyId = stringValue(source.legacyId ?? source.legacy_id ?? source.externalId ?? source.external_id ?? source.id) || null; + const base = { + kind: kind || 'record', + legacyId, + regionId, + metadata: objectValue(source.metadata), + }; + + let normalizedWithoutHash: Omit; + if (kind === 'field') { + const fieldKey = stringValue(source.fieldKey ?? source.field_key ?? source.key); + const fieldName = stringValue(source.fieldName ?? source.field_name ?? source.name ?? source.label); + if (!fieldKey) { + issues.push({ rowNo, severity: 'error', code: 'SCORELINE_FIELD_KEY_REQUIRED', fieldPath: 'fieldKey', message: 'scoreline fieldKey is required' }); + } + if (!fieldName) { + issues.push({ rowNo, severity: 'error', code: 'SCORELINE_FIELD_NAME_REQUIRED', fieldPath: 'fieldName', message: 'scoreline fieldName is required' }); + } + normalizedWithoutHash = { + ...base, + kind: 'field', + fieldKey, + fieldName, + fieldType: stringValue(source.fieldType ?? source.field_type) || 'number', + unit: stringValue(source.unit) || null, + isFilter: boolishValue(source.isFilter ?? source.is_filter, false), + isRequired: boolishValue(source.isRequired ?? source.is_required, false), + isVisible: boolishValue(source.isVisible ?? source.is_visible, true), + isTrend: boolishValue(source.isTrend ?? source.is_trend, false), + options: stringArrayValue(source.options), + placeholder: stringValue(source.placeholder) || null, + description: stringValue(source.description) || null, + order: normalizeSortOrder(source.sortOrder ?? source.sort_order ?? source.order, rowNo), + }; + } else if (kind === 'school') { + const schoolName = stringValue(source.name ?? source.schoolName ?? source.school_name); + if (!schoolName) { + issues.push({ rowNo, severity: 'error', code: 'SCORELINE_SCHOOL_NAME_REQUIRED', fieldPath: 'name', message: 'scoreline school name is required' }); + } + normalizedWithoutHash = { + ...base, + kind: 'school', + schoolName, + schoolShortName: stringValue(source.shortName ?? source.short_name) || null, + schoolType: stringValue(source.schoolType ?? source.type) || null, + isHot: boolishValue(source.isHot ?? source.is_hot, false), + order: normalizeSortOrder(source.sortOrder ?? source.sort_order ?? source.order, rowNo), + }; + } else if (kind === 'major') { + const schoolId = nullableUuidValue(source.schoolId ?? source.school_id, issues, rowNo, 'schoolId'); + const schoolLegacyId = stringValue(source.schoolLegacyId ?? source.school_legacy_id) || null; + const schoolName = stringValue(source.schoolName ?? source.school_name) || null; + const majorName = stringValue(source.name ?? source.majorName ?? source.major_name); + if (!schoolId && !schoolLegacyId && !schoolName) { + issues.push({ rowNo, severity: 'error', code: 'SCORELINE_MAJOR_SCHOOL_REQUIRED', fieldPath: 'schoolId', message: 'major requires schoolId, schoolLegacyId, or schoolName' }); + } + if (!majorName) { + issues.push({ rowNo, severity: 'error', code: 'SCORELINE_MAJOR_NAME_REQUIRED', fieldPath: 'name', message: 'scoreline major name is required' }); + } + normalizedWithoutHash = { + ...base, + kind: 'major', + schoolId, + schoolLegacyId, + schoolName, + majorName, + order: normalizeSortOrder(source.sortOrder ?? source.sort_order ?? source.order, rowNo), + hasRestriction: boolishValue(source.hasRestriction ?? source.has_restriction, false), + restrictionDesc: stringValue(source.restrictionDesc ?? source.restriction_desc) || null, + }; + } else { + const schoolId = nullableUuidValue(source.schoolId ?? source.school_id, issues, rowNo, 'schoolId'); + const majorId = nullableUuidValue(source.majorId ?? source.major_id, issues, rowNo, 'majorId'); + const schoolLegacyId = stringValue(source.schoolLegacyId ?? source.school_legacy_id) || null; + const majorLegacyId = stringValue(source.majorLegacyId ?? source.major_legacy_id) || null; + const schoolName = stringValue(source.schoolName ?? source.school_name) || null; + const majorName = stringValue(source.majorName ?? source.major_name) || null; + const year = integerValue(source.year, 0); + if (!year || year < 1900 || year > 2200) { + issues.push({ rowNo, severity: 'error', code: 'SCORELINE_YEAR_INVALID', fieldPath: 'year', message: 'scoreline record year must be a valid year' }); + } + if (!schoolId && !schoolLegacyId && !schoolName) { + issues.push({ rowNo, severity: 'error', code: 'SCORELINE_RECORD_SCHOOL_REQUIRED', fieldPath: 'schoolId', message: 'record requires schoolId, schoolLegacyId, or schoolName' }); + } + normalizedWithoutHash = { + ...base, + kind: 'record', + schoolId, + schoolLegacyId, + schoolName, + majorId, + majorLegacyId, + majorName, + year, + fieldValues: scorelineFieldValues(source), + }; + } + + const normalized: NormalizedScorelineItem = { + ...normalizedWithoutHash, + sourceHash: contentHash(normalizedWithoutHash), + }; + + return { normalized, issues }; +} + +function createScorelineNormalizedItems(body: JsonObject): NormalizedImportItem[] { + const source = parseJsonMaybe(body.items ?? body.payload ?? body, 'INVALID_SCORELINE_IMPORT_PAYLOAD'); + const payload = objectValue(source); + const defaultRegionId = nullableString(body.regionId) || nullableString(payload.regionId ?? payload.region_id); + const buckets = [ + ...asArray(payload.fields ?? payload.scorelineFields ?? payload.scoreline_fields).map(item => ({ ...objectValue(item), kind: 'field' })), + ...asArray(payload.schools ?? payload.scorelineSchools ?? payload.scoreline_schools).map(item => ({ ...objectValue(item), kind: 'school' })), + ...asArray(payload.majors ?? payload.scorelineMajors ?? payload.scoreline_majors).map(item => ({ ...objectValue(item), kind: 'major' })), + ...asArray(payload.records ?? payload.scorelineRecords ?? payload.scoreline_records).map(item => ({ ...objectValue(item), kind: 'record' })), + ]; + const rawItems = Array.isArray(source) ? source : buckets.length ? buckets : asArray(payload.items); + if (rawItems.length === 0) { + throw new HttpError(400, 'Scoreline import payload must contain at least one item', 'EMPTY_IMPORT_PAYLOAD'); + } + if (rawItems.length > 3000) { + throw new HttpError(400, 'A single scoreline import job can contain at most 3000 items', 'IMPORT_TOO_LARGE'); + } + + return rawItems.map((raw, index) => { + const rowNo = index + 1; + const { normalized, issues } = normalizeScorelineItem(raw, rowNo, defaultRegionId); + return { + rowNo, + status: issues.some(issue => issue.severity === 'error') ? 'invalid' : 'valid', + externalId: normalized.legacyId || null, + source: raw, + normalized, + issues, + }; + }); +} + +function normalizeVideoAccessMode(value: unknown, issues: ImportIssue[], rowNo: number) { + const normalized = stringValue(value).toLowerCase(); + if (!normalized) return 'svip'; + if (['free', 'svip', 'video_quota'].includes(normalized)) return normalized as NormalizedVideoImportItem['accessMode']; + issues.push({ + rowNo, + severity: 'error', + code: 'VIDEO_ACCESS_MODE_INVALID', + fieldPath: 'accessMode', + message: 'video accessMode must be free, svip, or video_quota', + }); + return 'svip'; +} + +function normalizeVideoBinding(raw: unknown, rowNo: number, index: number, issues: ImportIssue[]): NormalizedVideoBinding { + const source = objectValue(raw); + const questionId = nullableUuidValue(source.questionId ?? source.question_id, issues, rowNo, `bindings[${index}].questionId`); + const legacyQuestionId = stringValue(source.legacyQuestionId ?? source.legacy_question_id ?? source.questionLegacyId ?? source.question_legacy_id) || null; + if (!questionId && !legacyQuestionId) { + issues.push({ + rowNo, + severity: 'error', + code: 'VIDEO_BINDING_QUESTION_REQUIRED', + fieldPath: `bindings[${index}].questionId`, + message: 'video binding requires questionId or legacyQuestionId', + }); + } + return { + legacyId: stringValue(source.legacyId ?? source.legacy_id ?? source.externalId ?? source.external_id) || null, + questionId, + legacyQuestionId, + videoType: stringValue(source.videoType ?? source.video_type) || 'specific', + order: integerValue(source.order ?? source.sortOrder ?? source.sort_order, index), + }; +} + +function normalizeVideoImportItem(raw: unknown, rowNo: number) { + const issues: ImportIssue[] = []; + const source = objectValue(raw); + const title = stringValue(source.title ?? source.name); + if (!title) { + issues.push({ rowNo, severity: 'error', code: 'VIDEO_TITLE_REQUIRED', fieldPath: 'title', message: 'video title is required' }); + } + const videoUrl = stringValue(source.videoUrl ?? source.video_url ?? source.url) || null; + const assetId = nullableUuidValue(source.assetId ?? source.asset_id, issues, rowNo, 'assetId'); + if (!videoUrl && !assetId) { + issues.push({ + rowNo, + severity: 'warning', + code: 'VIDEO_PLAYABLE_SOURCE_MISSING', + fieldPath: 'videoUrl', + message: 'video has no videoUrl or assetId; it can be saved but playback will be unavailable until a resource is bound', + }); + } + const subjectId = nullableUuidValue(source.subjectId ?? source.subject_id, issues, rowNo, 'subjectId'); + const directQuestionId = source.questionId ?? source.question_id; + const directLegacyQuestionId = source.legacyQuestionId ?? source.legacy_question_id ?? source.questionLegacyId ?? source.question_legacy_id; + const bindings = [ + ...asArray(source.bindings ?? source.questionBindings ?? source.question_bindings), + ...(directQuestionId || directLegacyQuestionId + ? [{ + questionId: directQuestionId, + legacyQuestionId: directLegacyQuestionId, + videoType: source.videoType ?? source.video_type, + order: source.bindingOrder ?? source.binding_order ?? source.order, + }] + : []), + ].map((binding, index) => normalizeVideoBinding(binding, rowNo, index, issues)); + + const normalizedWithoutHash = { + legacyId: stringValue(source.legacyId ?? source.legacy_id ?? source.externalId ?? source.external_id ?? source.id) || null, + title, + description: stringValue(source.description) || null, + videoUrl, + thumbnailUrl: stringValue(source.thumbnailUrl ?? source.thumbnail_url) || null, + duration: source.duration === undefined && source.durationSeconds === undefined && source.duration_seconds === undefined + ? null + : Math.max(0, integerValue(source.duration ?? source.durationSeconds ?? source.duration_seconds, 0)), + knowledgeTags: stringArrayValue(source.knowledgeTags ?? source.knowledge_tags ?? source.tags), + isGeneral: boolishValue(source.isGeneral ?? source.is_general, false), + subjectId, + difficulty: nullableDifficulty(source.difficulty, issues, rowNo, 'difficulty'), + order: normalizeSortOrder(source.sortOrder ?? source.sort_order ?? source.order, rowNo), + isActive: boolishValue(source.isActive ?? source.is_active, true), + assetId, + accessMode: normalizeVideoAccessMode(source.accessMode ?? source.access_mode, issues, rowNo), + freePreviewSeconds: Math.max(0, integerValue(source.freePreviewSeconds ?? source.free_preview_seconds, 0)), + bindings, + metadata: objectValue(source.metadata), + }; + + const normalized: NormalizedVideoImportItem = { + ...normalizedWithoutHash, + sourceHash: contentHash(normalizedWithoutHash), + }; + return { normalized, issues }; +} + +function createVideoNormalizedItems(body: JsonObject): NormalizedImportItem[] { + return parseBoundedItems(body, ['items', 'videos', 'videoExplanations', 'video_explanations'], 'INVALID_VIDEO_IMPORT_PAYLOAD', 'video', 2000) + .map((raw, index) => { + const rowNo = index + 1; + const { normalized, issues } = normalizeVideoImportItem(raw, rowNo); + return { + rowNo, + status: issues.some(issue => issue.severity === 'error') ? 'invalid' : 'valid', + externalId: normalized.legacyId || null, + source: raw, + normalized, + issues, + }; + }); +} + async function assertGenericTargetReferences( client: pg.PoolClient, auth: TenantContentAuth, body: JsonObject, - expectedEntryType: 'vocabulary' | 'handbook', + expectedEntryType: GenericTargetEntryType, ): Promise { const regionId = nullableString(body.regionId); let entryId = nullableString(body.entryId); @@ -1212,7 +1631,7 @@ async function assertGenericTargetReferences( [auth.tenantId, entryId], ); if (!entry.rows[0]) throw new HttpError(400, 'entryId is not in this tenant', 'ENTRY_NOT_FOUND'); - if (entry.rows[0].entry_type !== expectedEntryType) { + if (expectedEntryType && entry.rows[0].entry_type !== expectedEntryType) { throw new HttpError(400, `entryId must be a ${expectedEntryType} content entry`, 'ENTRY_TYPE_MISMATCH'); } } @@ -1235,9 +1654,10 @@ async function assertGenericTargetReferences( async function createGenericPreviewJob( auth: TenantContentAuth, body: JsonObject, - importType: 'vocabulary' | 'handbook', + importType: ContentImportType, targetType: string, normalizedItems: NormalizedImportItem[], + expectedEntryType: GenericTargetEntryType = importType === 'vocabulary' || importType === 'handbook' ? importType : null, ): Promise> { const sourceFormat = stringValue(body.sourceFormat) || 'json'; const sourceName = stringValue(body.sourceName) || null; @@ -1246,7 +1666,7 @@ async function createGenericPreviewJob( } return transaction(async client => { - const target = await assertGenericTargetReferences(client, auth, body, importType); + const target = await assertGenericTargetReferences(client, auth, body, expectedEntryType); const issues = normalizedItems.flatMap(item => item.issues); const errorCount = issues.filter(issue => issue.severity === 'error').length; const warningCount = issues.filter(issue => issue.severity === 'warning').length; @@ -1412,7 +1832,7 @@ async function loadGenericPreviewJob( client: pg.PoolClient, auth: TenantContentAuth, jobId: string, - importType: 'vocabulary' | 'handbook', + importType: ContentImportType, ) { const result = await client.query<{ id: string; @@ -1444,6 +1864,97 @@ async function loadGenericPreviewJob( return job; } +async function assertTenantReference( + client: pg.PoolClient, + auth: TenantContentAuth, + table: string, + id: string | null, + code: string, +) { + if (!id) return; + const result = await client.query(`select id from public.${table} where tenant_id = $1 and id = $2 limit 1`, [auth.tenantId, id]); + if (!result.rows[0]) { + throw new HttpError(400, `${table} reference is not in this tenant`, code); + } +} + +async function resolveScorelineSchool( + client: pg.PoolClient, + auth: TenantContentAuth, + normalized: Pick, +) { + if (normalized.schoolId) { + const byId = await client.query<{ id: string; name: string | null }>( + 'select id, name from public.scoreline_schools where tenant_id = $1 and id = $2 limit 1', + [auth.tenantId, normalized.schoolId], + ); + if (!byId.rows[0]) throw new HttpError(400, 'schoolId is not in this tenant', 'SCORELINE_SCHOOL_NOT_FOUND'); + return byId.rows[0]; + } + if (normalized.schoolLegacyId) { + const byLegacy = await client.query<{ id: string; name: string | null }>( + 'select id, name from public.scoreline_schools where tenant_id = $1 and legacy_id = $2 limit 1', + [auth.tenantId, normalized.schoolLegacyId], + ); + if (byLegacy.rows[0]) return byLegacy.rows[0]; + } + if (normalized.schoolName) { + const byName = await client.query<{ id: string; name: string | null }>( + ` + select id, name + from public.scoreline_schools + where tenant_id = $1 + and ($2::uuid is null or region_id = $2::uuid) + and name = $3 + order by created_at asc + limit 1 + `, + [auth.tenantId, normalized.regionId, normalized.schoolName], + ); + if (byName.rows[0]) return byName.rows[0]; + } + throw new HttpError(400, 'scoreline school reference was not found', 'SCORELINE_SCHOOL_NOT_FOUND'); +} + +async function resolveScorelineMajor( + client: pg.PoolClient, + auth: TenantContentAuth, + normalized: NormalizedScorelineItem, + schoolId: string | null, +) { + if (normalized.majorId) { + const byId = await client.query<{ id: string; name: string | null }>( + 'select id, name from public.scoreline_majors where tenant_id = $1 and id = $2 limit 1', + [auth.tenantId, normalized.majorId], + ); + if (!byId.rows[0]) throw new HttpError(400, 'majorId is not in this tenant', 'SCORELINE_MAJOR_NOT_FOUND'); + return byId.rows[0]; + } + if (normalized.majorLegacyId) { + const byLegacy = await client.query<{ id: string; name: string | null }>( + 'select id, name from public.scoreline_majors where tenant_id = $1 and legacy_id = $2 limit 1', + [auth.tenantId, normalized.majorLegacyId], + ); + if (byLegacy.rows[0]) return byLegacy.rows[0]; + } + if (normalized.majorName) { + const byName = await client.query<{ id: string; name: string | null }>( + ` + select id, name + from public.scoreline_majors + where tenant_id = $1 + and ($2::uuid is null or school_id = $2::uuid) + and name = $3 + order by created_at asc + limit 1 + `, + [auth.tenantId, schoolId, normalized.majorName], + ); + if (byName.rows[0]) return byName.rows[0]; + } + return null; +} + function ltreeLabel(id: string) { return `n_${id.replace(/-/g, '')}`; } @@ -2170,10 +2681,381 @@ async function importOneHandbookSubject( return { insertedCount, updatedCount, skippedCount }; } +async function importOneScorelineItem( + client: pg.PoolClient, + auth: TenantContentAuth, + job: { + id: string; + target_region_id: string | null; + }, + item: { + id: string; + row_no: number; + normalized_payload: NormalizedScorelineItem; + }, +) { + const normalized = item.normalized_payload; + const regionId = normalized.regionId || job.target_region_id; + if (regionId) await assertTenantReference(client, auth, 'regions', regionId, 'REGION_NOT_FOUND'); + let targetId = ''; + let insertedCount = 0; + let updatedCount = 0; + let skippedCount = 0; + + if (normalized.kind === 'field') { + const legacyId = normalized.legacyId || `scoreline-field:${regionId || 'global'}:${normalized.fieldKey}`; + const existing = await client.query<{ id: string; source_hash: string | null }>( + 'select id, source_hash from public.scoreline_fields where tenant_id = $1 and legacy_id = $2 limit 1 for update', + [auth.tenantId, legacyId], + ); + const previousHash = existing.rows[0]?.source_hash || null; + const row = await client.query<{ id: string }>( + ` + insert into public.scoreline_fields ( + tenant_id, region_id, legacy_id, field_key, field_name, field_type, + unit, is_filter, is_required, is_visible, is_trend, options, + placeholder, description, sort_order, source_hash, metadata + ) + values ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, $15, $16, $17::jsonb) + on conflict (tenant_id, region_id, field_key) + do update set legacy_id = excluded.legacy_id, + field_name = excluded.field_name, + field_type = excluded.field_type, + unit = excluded.unit, + is_filter = excluded.is_filter, + is_required = excluded.is_required, + is_visible = excluded.is_visible, + is_trend = excluded.is_trend, + options = excluded.options, + placeholder = excluded.placeholder, + description = excluded.description, + sort_order = excluded.sort_order, + source_hash = excluded.source_hash, + metadata = excluded.metadata, + updated_at = now() + returning id + `, + [ + auth.tenantId, + regionId, + legacyId, + normalized.fieldKey, + normalized.fieldName, + normalized.fieldType || 'number', + normalized.unit || null, + normalized.isFilter === true, + normalized.isRequired === true, + normalized.isVisible !== false, + normalized.isTrend === true, + JSON.stringify(normalized.options || []), + normalized.placeholder || null, + normalized.description || null, + normalized.order || 0, + normalized.sourceHash, + JSON.stringify({ ...normalized.metadata, source: 'content_import', importJobId: job.id }), + ], + ); + targetId = row.rows[0].id; + if (!existing.rows[0]) insertedCount = 1; + else if (previousHash === normalized.sourceHash) skippedCount = 1; + else updatedCount = 1; + } else if (normalized.kind === 'school') { + const legacyId = normalized.legacyId || `scoreline-school:${regionId || 'global'}:${normalized.schoolName}`; + const existing = await client.query<{ id: string; source_hash: string | null }>( + 'select id, source_hash from public.scoreline_schools where tenant_id = $1 and legacy_id = $2 limit 1 for update', + [auth.tenantId, legacyId], + ); + const previousHash = existing.rows[0]?.source_hash || null; + const row = await client.query<{ id: string }>( + ` + insert into public.scoreline_schools ( + tenant_id, region_id, legacy_id, name, short_name, type, is_hot, sort_order, source_hash, metadata + ) + values ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10::jsonb) + on conflict (tenant_id, legacy_id) + do update set region_id = excluded.region_id, + name = excluded.name, + short_name = excluded.short_name, + type = excluded.type, + is_hot = excluded.is_hot, + sort_order = excluded.sort_order, + source_hash = excluded.source_hash, + metadata = excluded.metadata, + updated_at = now() + returning id + `, + [ + auth.tenantId, + regionId, + legacyId, + normalized.schoolName, + normalized.schoolShortName || null, + normalized.schoolType || null, + normalized.isHot === true, + normalized.order || 0, + normalized.sourceHash, + JSON.stringify({ ...normalized.metadata, source: 'content_import', importJobId: job.id }), + ], + ); + targetId = row.rows[0].id; + if (!existing.rows[0]) insertedCount = 1; + else if (previousHash === normalized.sourceHash) skippedCount = 1; + else updatedCount = 1; + } else if (normalized.kind === 'major') { + const school = await resolveScorelineSchool(client, auth, normalized); + const legacyId = normalized.legacyId || `scoreline-major:${school.id}:${normalized.majorName}`; + const existing = await client.query<{ id: string; source_hash: string | null }>( + 'select id, source_hash from public.scoreline_majors where tenant_id = $1 and legacy_id = $2 limit 1 for update', + [auth.tenantId, legacyId], + ); + const previousHash = existing.rows[0]?.source_hash || null; + const row = await client.query<{ id: string }>( + ` + insert into public.scoreline_majors ( + tenant_id, region_id, school_id, legacy_id, name, sort_order, + has_restriction, restriction_desc, source_hash, metadata + ) + values ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10::jsonb) + on conflict (tenant_id, legacy_id) + do update set region_id = excluded.region_id, + school_id = excluded.school_id, + name = excluded.name, + sort_order = excluded.sort_order, + has_restriction = excluded.has_restriction, + restriction_desc = excluded.restriction_desc, + source_hash = excluded.source_hash, + metadata = excluded.metadata, + updated_at = now() + returning id + `, + [ + auth.tenantId, + regionId, + school.id, + legacyId, + normalized.majorName, + normalized.order || 0, + normalized.hasRestriction === true, + normalized.restrictionDesc || null, + normalized.sourceHash, + JSON.stringify({ ...normalized.metadata, source: 'content_import', importJobId: job.id }), + ], + ); + targetId = row.rows[0].id; + if (!existing.rows[0]) insertedCount = 1; + else if (previousHash === normalized.sourceHash) skippedCount = 1; + else updatedCount = 1; + } else { + const school = await resolveScorelineSchool(client, auth, normalized); + const major = await resolveScorelineMajor(client, auth, normalized, school.id); + const legacyId = normalized.legacyId || `scoreline-record:${school.id}:${major?.id || 'none'}:${normalized.year}`; + const existing = await client.query<{ id: string; source_hash: string | null }>( + 'select id, source_hash from public.scoreline_records where tenant_id = $1 and legacy_id = $2 limit 1 for update', + [auth.tenantId, legacyId], + ); + const previousHash = existing.rows[0]?.source_hash || null; + const row = await client.query<{ id: string }>( + ` + insert into public.scoreline_records ( + tenant_id, region_id, school_id, major_id, legacy_id, + year, school_name, major_name, field_values, source_hash, metadata + ) + values ($1, $2::uuid, $3::uuid, $4::uuid, $5, $6, $7, $8, $9::jsonb, $10, $11::jsonb) + on conflict (tenant_id, legacy_id) + do update set region_id = excluded.region_id, + school_id = excluded.school_id, + major_id = excluded.major_id, + year = excluded.year, + school_name = excluded.school_name, + major_name = excluded.major_name, + field_values = excluded.field_values, + source_hash = excluded.source_hash, + metadata = excluded.metadata, + updated_at = now() + returning id + `, + [ + auth.tenantId, + regionId, + school.id, + major?.id || null, + legacyId, + normalized.year, + normalized.schoolName || school.name, + normalized.majorName || major?.name || null, + JSON.stringify(normalized.fieldValues || {}), + normalized.sourceHash, + JSON.stringify({ ...normalized.metadata, source: 'content_import', importJobId: job.id }), + ], + ); + targetId = row.rows[0].id; + if (!existing.rows[0]) insertedCount = 1; + else if (previousHash === normalized.sourceHash) skippedCount = 1; + else updatedCount = 1; + } + + const itemStatus = insertedCount > 0 ? 'inserted' : updatedCount > 0 ? 'updated' : 'skipped'; + await client.query( + ` + update public.content_import_items + set status = $3, target_id = $4, updated_at = now() + where tenant_id = $1 and id = $2 + `, + [auth.tenantId, item.id, itemStatus, targetId], + ); + return { insertedCount, updatedCount, skippedCount }; +} + +async function importOneVideo( + client: pg.PoolClient, + auth: TenantContentAuth, + job: { + id: string; + }, + item: { + id: string; + row_no: number; + normalized_payload: NormalizedVideoImportItem; + }, +) { + const normalized = item.normalized_payload; + if (normalized.subjectId) await assertTenantReference(client, auth, 'subjects', normalized.subjectId, 'SUBJECT_NOT_FOUND'); + if (normalized.assetId) await assertTenantReference(client, auth, 'content_assets', normalized.assetId, 'ASSET_NOT_FOUND'); + const legacyId = normalized.legacyId || `content-import:${job.id}:video:${item.row_no}`; + const existing = await client.query<{ id: string; source_hash: string | null }>( + 'select id, source_hash from public.video_explanations where tenant_id = $1 and legacy_id = $2 limit 1 for update', + [auth.tenantId, legacyId], + ); + const previousHash = existing.rows[0]?.source_hash || null; + const video = await client.query<{ id: string }>( + ` + insert into public.video_explanations ( + tenant_id, legacy_id, title, description, video_url, thumbnail_url, + duration_seconds, knowledge_tags, is_general, subject_id, difficulty, + sort_order, is_active, asset_id, access_mode, free_preview_seconds, source_hash, metadata + ) + values ( + $1, $2, $3, $4, $5, $6, + $7, $8::jsonb, $9, $10::uuid, $11, + $12, $13, $14::uuid, $15, $16, $17, $18::jsonb + ) + on conflict (tenant_id, legacy_id) + do update set title = excluded.title, + description = excluded.description, + video_url = excluded.video_url, + thumbnail_url = excluded.thumbnail_url, + duration_seconds = excluded.duration_seconds, + knowledge_tags = excluded.knowledge_tags, + is_general = excluded.is_general, + subject_id = excluded.subject_id, + difficulty = excluded.difficulty, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + asset_id = excluded.asset_id, + access_mode = excluded.access_mode, + free_preview_seconds = excluded.free_preview_seconds, + source_hash = excluded.source_hash, + metadata = excluded.metadata, + updated_at = now() + returning id + `, + [ + auth.tenantId, + legacyId, + normalized.title, + normalized.description, + normalized.videoUrl, + normalized.thumbnailUrl, + normalized.duration, + JSON.stringify(normalized.knowledgeTags), + normalized.isGeneral, + normalized.subjectId, + normalized.difficulty, + normalized.order, + normalized.isActive, + normalized.assetId, + normalized.accessMode, + normalized.freePreviewSeconds, + normalized.sourceHash, + JSON.stringify({ ...normalized.metadata, source: 'content_import', importJobId: job.id }), + ], + ); + + let insertedCount = existing.rows[0] ? 0 : 1; + let updatedCount = existing.rows[0] && previousHash !== normalized.sourceHash ? 1 : 0; + let skippedCount = existing.rows[0] && previousHash === normalized.sourceHash ? 1 : 0; + + for (const [index, binding] of normalized.bindings.entries()) { + let questionId = binding.questionId; + if (!questionId && binding.legacyQuestionId) { + const question = await client.query<{ id: string }>( + 'select id from public.questions where tenant_id = $1 and legacy_id = $2 limit 1', + [auth.tenantId, binding.legacyQuestionId], + ); + questionId = question.rows[0]?.id || null; + } + if (!questionId) { + throw new HttpError(400, 'video binding question was not found', 'QUESTION_NOT_FOUND'); + } + await assertTenantReference(client, auth, 'questions', questionId, 'QUESTION_NOT_FOUND'); + const bindingLegacyId = binding.legacyId || `${legacyId}:question:${questionId}:${index + 1}`; + const existingBinding = await client.query<{ id: string }>( + 'select id from public.question_videos where tenant_id = $1 and legacy_id = $2 limit 1 for update', + [auth.tenantId, bindingLegacyId], + ); + await client.query( + ` + insert into public.question_videos ( + tenant_id, question_id, video_id, legacy_id, legacy_question_id, + legacy_video_id, video_type, sort_order, metadata + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb) + on conflict (tenant_id, legacy_id) + do update set question_id = excluded.question_id, + video_id = excluded.video_id, + legacy_question_id = excluded.legacy_question_id, + legacy_video_id = excluded.legacy_video_id, + video_type = excluded.video_type, + sort_order = excluded.sort_order, + metadata = excluded.metadata, + updated_at = now() + `, + [ + auth.tenantId, + questionId, + video.rows[0].id, + bindingLegacyId, + binding.legacyQuestionId, + legacyId, + binding.videoType, + binding.order, + JSON.stringify({ source: 'content_import', importJobId: job.id }), + ], + ); + await client.query('update public.questions set has_video_explanation = true, updated_at = now() where tenant_id = $1 and id = $2', [ + auth.tenantId, + questionId, + ]); + if (!existingBinding.rows[0]) insertedCount += 1; + else updatedCount += 1; + } + + const itemStatus = insertedCount > 0 ? 'inserted' : updatedCount > 0 ? 'updated' : 'skipped'; + await client.query( + ` + update public.content_import_items + set status = $3, target_id = $4, updated_at = now() + where tenant_id = $1 and id = $2 + `, + [auth.tenantId, item.id, itemStatus, video.rows[0].id], + ); + return { insertedCount, updatedCount, skippedCount }; +} + async function runGenericImport( auth: TenantContentAuth, body: JsonObject, - importType: 'vocabulary' | 'handbook', + importType: ContentImportType, createPreview: () => Promise>, importOne: ( client: pg.PoolClient, @@ -2239,10 +3121,22 @@ async function runGenericImport( select id, row_no, normalized_payload from public.content_import_items where tenant_id = $1 and job_id = $2 and status = 'valid' - order by row_no asc + order by + case + when $3 = 'scoreline' then + case normalized_payload ->> 'kind' + when 'field' then 1 + when 'school' then 2 + when 'major' then 3 + when 'record' then 4 + else 9 + end + else 1 + end, + row_no asc for update `, - [auth.tenantId, job.id], + [auth.tenantId, job.id, importType], ); let insertedCount = 0; @@ -2325,6 +3219,18 @@ export async function previewHandbookImportRoute(ctx: RequestContext) { return createGenericPreviewJob(auth, body, 'handbook', 'handbook_subject', createHandbookNormalizedItems(body)); } +export async function previewScorelineImportRoute(ctx: RequestContext) { + const auth = await requireTenantContentEditor(ctx); + const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); + return createGenericPreviewJob(auth, body, 'scoreline', 'scoreline_item', createScorelineNormalizedItems(body), null); +} + +export async function previewVideosImportRoute(ctx: RequestContext) { + const auth = await requireTenantContentEditor(ctx); + const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); + return createGenericPreviewJob(auth, body, 'videos', 'video_explanation', createVideoNormalizedItems(body), null); +} + export async function importQuestionsRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); @@ -2464,6 +3370,30 @@ export async function importHandbookRoute(ctx: RequestContext) { ); } +export async function importScorelineRoute(ctx: RequestContext) { + const auth = await requireTenantContentEditor(ctx); + const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); + return runGenericImport( + auth, + body, + 'scoreline', + () => createGenericPreviewJob(auth, body, 'scoreline', 'scoreline_item', createScorelineNormalizedItems(body), null), + importOneScorelineItem, + ); +} + +export async function importVideosRoute(ctx: RequestContext) { + const auth = await requireTenantContentEditor(ctx); + const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); + return runGenericImport( + auth, + body, + 'videos', + () => createGenericPreviewJob(auth, body, 'videos', 'video_explanation', createVideoNormalizedItems(body), null), + importOneVideo, + ); +} + export async function importJobsRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const limit = intParam(ctx, 'limit', 50, 200); diff --git a/apps/api/src/features/tenant-content/index.ts b/apps/api/src/features/tenant-content/index.ts index cbd4e7f8..f725e53c 100644 --- a/apps/api/src/features/tenant-content/index.ts +++ b/apps/api/src/features/tenant-content/index.ts @@ -12,9 +12,13 @@ import { importIssuesRoute, importJobsRoute, importQuestionsRoute, + importScorelineRoute, + importVideosRoute, importVocabularyRoute, previewHandbookImportRoute, previewQuestionsImportRoute, + previewScorelineImportRoute, + previewVideosImportRoute, previewVocabularyImportRoute, } from './imports.js'; import { @@ -84,6 +88,10 @@ export const tenantContentRoutes: RouteDefinition[] = [ ['POST', '/api/tenant-content/imports/vocabulary', importVocabularyRoute], ['POST', '/api/tenant-content/imports/preview/handbook', previewHandbookImportRoute], ['POST', '/api/tenant-content/imports/handbook', importHandbookRoute], + ['POST', '/api/tenant-content/imports/preview/scoreline', previewScorelineImportRoute], + ['POST', '/api/tenant-content/imports/scoreline', importScorelineRoute], + ['POST', '/api/tenant-content/imports/preview/videos', previewVideosImportRoute], + ['POST', '/api/tenant-content/imports/videos', importVideosRoute], ['GET', '/api/tenant-content/imports', importJobsRoute], ['GET', '/api/tenant-content/imports/issues', importIssuesRoute], ['GET', '/api/tenant-content/videos', videosAdminRoute], diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 61758076..7be00c15 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -78,8 +78,10 @@ | 知识手册目录/内容 | 可联调 | `/api/catalog/handbook-*` | | 知识手册 JSON 导入 | 可联调 | `/api/tenant-content/imports/*/handbook` | | 分数线字段/院校/专业/记录/趋势 | 可联调 | `/api/scoreline/*` | +| 分数线 JSON 导入 | 可联调 | `/api/tenant-content/imports/preview/scoreline`、`/api/tenant-content/imports/scoreline`;支持字段、院校、专业、记录、动态字段值、逐行 issue、幂等和审计 | | 题目视频/批量预加载/搜索 | 可联调 | `/api/questions/*/videos`、`/api/videos/search`;付费视频列表不返回可播放 URL | | 视频会员播放次数 | 可联调 | `POST /api/videos/play` 支持 SVIP/视频次数校验、签名播放、次数扣减、播放日志;深度防盗链和动态水印继续补 | +| 视频 JSON 导入和批量绑定 | 可联调 | `/api/tenant-content/imports/preview/videos`、`/api/tenant-content/imports/videos`;支持视频元数据、资源引用、播放模式、题目绑定和题目视频标记 | ## 资料与对象存储 @@ -157,8 +159,9 @@ | 公共题库采纳快照 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;已覆盖跨租户、重复采纳和采纳后组卷测试 | | 单词 JSON preview/import | 可联调 | 兼容旧模板 | | 知识手册 JSON preview/import | 可联调 | 支持书籍/章节/小节/知识点归一化 | +| 分数线 JSON preview/import | 可联调 | 支持 `fields/schools/majors/records` 分桶或 `items` 列表,后端校验租户地区和院校/专业引用 | +| 视频 JSON preview/import | 可联调 | 支持 `videos/items`,后端校验题目、科目、资源引用,导入后写入 `question_videos` | | Excel/CSV 导入 | 待补齐 | 应复用 `content_import_jobs` 管线 | -| 分数线/视频批量导入 | 待补齐 | 应复用同一导入管线 | | 大批量异步导入 | 待补齐 | 需要 `apps/worker` | | 公共题库版本同步 | 待补齐 | 当前采纳为快照复制;后续需 worker 做增量同步、冲突处理、版本升级通知和租户自改保护 | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index b20baa17..fd236045 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -12,7 +12,7 @@ - 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册和资料资源。 - 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API。 - 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置和入队能力。 -- 旧题库 JSON、单词模板、知识手册嵌套模板已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。 +- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。 因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实鉴权、对象存储、支付、短信、微信/QQ 登录、异步 worker、真实数据 dry-run 迁移仍需要继续补齐。 @@ -26,13 +26,13 @@ | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照 | 专项策略、公共题库版本同步、Excel 导入、排行榜防刷/预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON 导入、排行榜 | Excel 导入、更细复习参数 | | 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON 导入 | 富文本资源、版本管理、附件/PDF 关联 | -| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护 | 批量导入、复杂筛选、AI 择校上下文 | -| 视频解析 | 部分完成 | 单题视频、批量查询、后台视频绑定 | 会员播放权限、播放次数扣减、签名 URL、防盗链、水印 | +| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON 导入 | 复杂筛选、AI 择校上下文 | +| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON 导入、会员播放权限、播放次数扣减、签名 URL 和播放日志 | 深度防盗链、动态水印、播放统计 | | 资料下载 | 部分完成 | 资源台账、SVIP 权限校验、`local_dev`/阿里云 OSS/腾讯 COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、assets worker 复检异常下架 | PDF 渲染、CDN 防盗链、杀毒扫描、视频水印 | | 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 | | 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信小程序/网页登录、QQ 登录、Supabase Auth | | 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker | -| 内容导入 | 基础完成 | 题目、单词、知识手册 JSON preview/import、issue、job、审计、幂等 | Excel/CSV、分数线、视频导入,大批量异步 worker | +| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON preview/import、issue、job、审计、幂等 | Excel/CSV 解析、大批量异步 worker、导入后复检 | | 数据看板 | 数据基础具备 | 日统计/收入统计表和部分旧脚本 | 新 API 聚合、24h 活跃、运营动态、题型/科目/套餐看板 | | AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 | | Taro 前端 | 未开始 | 旧 Web 已有新 API 适配雏形 | `apps/taro`、跨端 API client、H5/小程序页面和端到端测试 | @@ -87,8 +87,8 @@ - XPay 或其它实际支付网关 adapter。 - 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。 - 公共题库/地区题库版本同步,租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。 -- Excel/CSV、分数线、视频批量导入。 -- 视频会员播放次数、播放日志、防盗链、水印。 +- Excel/CSV 解析、大批量异步导入和导入后复检。 +- 视频深度防盗链、动态水印和播放统计。 - 数据看板 API:收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。 ### P2:企业级体验和增长闭环 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 8175fa4b..6dd8a32b 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -15,7 +15,7 @@ - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。 - `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。 - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码批次、优惠券、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 - - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。 + - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。 - `tenant`:域名/租户解析。 - 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。 - 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。 @@ -31,6 +31,7 @@ - 已新增 `apps/worker` 和 `npm run test:worker:crm`,用于消费 CRM webhook 队列,验证本地 fake webhook、队列状态、日志和密钥不泄露。 - 已新增 commerce worker 和 `npm run test:worker:commerce`,用于补偿查询微信/支付宝支付、处理中退款和漏通知场景;支付成功会幂等更新订单/支付并开通权益,退款成功会幂等更新退款/订单/支付并在全额退款时撤销订单权益,测试覆盖密钥不泄露和重复执行不重复开通。 - 已新增 assets worker 和 `npm run test:worker:assets`,用于复检 `content_assets` 托管对象元数据;正常资源会写入复检证据,异常资源会自动下架为 `draft`、标记 `upload_status=failed`,并记录审计与安全标记。 +- 已新增分数线/视频 JSON 导入接口和集成测试:分数线支持字段、院校、专业、记录、动态字段值;视频支持视频元数据、资源引用、访问模式和题目绑定,全部复用 `content_import_jobs/items/issues` 管线。 ## 已验证接口 @@ -115,6 +116,10 @@ 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 +POST /api/tenant-content/imports/preview/scoreline +POST /api/tenant-content/imports/scoreline +POST /api/tenant-content/imports/preview/videos +POST /api/tenant-content/imports/videos GET /api/tenant-content/imports GET /api/tenant-content/imports/issues PUT /api/tenant-content/videos @@ -241,11 +246,11 @@ GET /api/tenant-admin/audit-logs - 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider;上传确认和 assets worker 已支持对象元数据校验/复检。PDF 预览渲染、防盗链、水印和安全扫描仍需继续补。 - 题库内容导航当前以 `content_entries/content_nodes` 为主模型,可表达“入口 -> 多级分类 -> 院校/专业/学科/销售意向标记”;题目集合和练习方式由 `question_collections/practice_blueprints` 管理,练习 session 会保存当次题目 ID 快照。 - 练习访问控制由 `content_entries/content_nodes/question_collections/practice_blueprints` 的 `accessRules` 合并决定;普通用户消耗 `practice_daily_usage`,事件写入 `practice_access_events`,SVIP/staff 不消耗免费额度。 -- 批量导入当前支持题目、单词、知识手册 JSON 预览、逐行 issue、job/item 台账、执行导入、幂等跳过,并可落到新内容入口和分类节点。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。Excel/CSV、分数线/视频导入会继续复用同一套 `content_import_jobs` 管线。 +- 批量导入当前支持题目、单词、知识手册、分数线、视频 JSON 预览、逐行 issue、job/item 台账、执行导入、幂等跳过,并可落到新内容入口、分类节点、分数线表或题目视频绑定。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。Excel/CSV 和大批量异步导入会继续复用同一套 `content_import_jobs` 管线。 ## 下一步 -1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,PDF 预览渲染、防盗链、杀毒扫描和视频水印。 +1. 完善内容导入和文件上传:Excel/CSV 解析、大批量异步导入、导入后复检,PDF 预览渲染、防盗链、杀毒扫描和视频水印。 2. 接入真实短信 provider:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。 3. 接入真实 OAuth provider:微信网页、微信小程序、QQ,并处理旧 PocketBase 身份映射。 4. 补完整资金流水对账、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认和退款通知主链路已完成。 diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index e8d1ee0c..06046f44 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -1,6 +1,6 @@ # SaaS 蓝图覆盖矩阵 -更新时间:2026-06-21 22:52 +更新时间:2026-06-29 ## 目标定位 @@ -17,28 +17,28 @@ | --- | --- | --- | --- | | 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略、地区/全国套餐权限、平台侧主题模板库、平台审计 | | 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 | -| 租户成员权限 | 基础完成 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,审计查询 | 前端权限 UI、自定义角色模板、菜单级可见配置 | -| 题库内容维护 | 基础完成 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目 JSON 预览/导入、视频绑定、分数线、单词、知识手册后台 API | Excel/CSV 批量导入、分数线/视频导入、公题库采纳/复制/授权、可视化拖拽排序前端 | +| 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 | +| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON 预览导入、视频绑定、分数线、单词、知识手册后台 API、公共题库授权和采纳快照 | Excel/CSV 批量导入、公共题库版本同步、可视化拖拽排序前端 | | 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹、模考交卷评分报告、错题复习计划、排行榜 | 专项练习策略、题型统计深度分析、排行榜防刷/预聚合 | | 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、每日复习计划、旧模板/新模板 JSON 预览导入、内容导航绑定、排行榜 | Excel 导入、更细复习参数 | | 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 | -| 分数线 | 基础完成 | 字段、院校、专业、记录、趋势、年份 | 复杂动态筛选、批量导入、AI 择校数据上下文 | -| 视频解析会员 | 部分完成 | 题目视频、批量查询、后台绑定 | SVIP 权限、播放次数扣减、签名 URL、防盗链、水印、播放统计 | -| 资料下载/PDF | 基础完成 | `content_assets` 资源台账、后台资源管理、上传/下载签名占位、学生端列表、SVIP 下载权限 | 真实 OSS/COS/Supabase Storage 签名、PDF 预览渲染、防盗链、资料前端管理页 | +| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 | +| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志 | 深度防盗链、动态水印、播放统计 | +| 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、assets worker 复检 | CDN 防盗链、杀毒扫描、资料前端管理页 | | 营销中心 | 基础完成 | SVIP 套餐、激活码批次、激活码生成、优惠券、Banner/FAQ/公告 | 勋章自动发放、复杂活动规则、核销报表 | -| 销售/代理客资 | 基础完成 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、小程序码占位 | 真实微信小程序码、分佣结算单、销售团队看板、代理费结算 | -| CRM 系统 | 基础完成 | CRM 配置、密钥私密存储、客资入队、队列查询 | worker 发送、钉钉/飞书/企微 adapter、重试签名、定向/轮询分配 | -| 数据看板 | 数据表部分具备 | `dashboard_daily_stats`、`revenue_daily_stats` 表、旧 backfill 脚本 | API 聚合、24h 活跃、收入趋势、题型/科目/套餐销售看板 | -| 登录认证 | 迁移期可用 | 短信 mock、迁移期 session、OAuth 配置表 | 阿里云/腾讯云短信、微信/QQ 登录真实 adapter、Supabase Auth/JWT | -| 支付 | 迁移期可用 | 订单、支付记录、手动确认、权益发放、租户商户配置 | 微信支付/支付宝/XPay adapter、webhook 幂等、退款 | +| 销售/代理客资 | 可联调 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、分佣比例、归因、结算单、审核和打款状态 | 真实微信小程序码、真实打款、结算导出、销售团队看板 | +| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 | +| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | +| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、OAuth 配置表 | 微信网页登录、QQ 登录、手机号换绑、真实生产账号联调 | +| 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知和补偿 worker | 完整资金流水对账、异常订单运营台、服务商/平台代收模式 | | AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI provider、JSON 报告 schema、PDF 报告生成 | | Taro 跨端 | 未开始 | 旧 Web 新 API 适配开始 | `apps/taro`、共享 API client、H5/小程序统一构建 | ## 接下来优先级 -1. 完善内容导入和对象存储:Excel/CSV、分数线/视频导入,真实 OSS/COS/Supabase Storage 签名,JSON 导入异步化。 -2. 公共题库/地区题库授权:平台题库向租户披露、租户采纳、按 SaaS 套餐限制地区。 +1. 完善内容导入和对象存储:Excel/CSV 解析、大批量异步导入、导入后复检、CDN 防盗链、杀毒扫描和视频水印。 +2. 公共题库/地区题库授权:已完成披露和采纳快照;继续补版本同步、租户自改冲突处理和按 SaaS 套餐限制地区。 3. 学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 -4. 视频会员控制:视频资源签名 URL、防盗链、水印、播放次数和会员权益。 -5. 数据看板 API:把旧 dashboard/revenue 统计迁到新 API。 +4. 视频会员控制:深度防盗链、水印和播放统计。 +5. 数据看板预聚合:把实时聚合升级为大租户可承载的日/周/月预聚合。 6. 真实 provider:短信、微信/QQ 登录、微信支付/支付宝;CRM worker 基础已落地,继续补分配策略和告警。 diff --git a/docs/refactor/content-import-contract.md b/docs/refactor/content-import-contract.md index 0143a9c0..f6bb111a 100644 --- a/docs/refactor/content-import-contract.md +++ b/docs/refactor/content-import-contract.md @@ -1,6 +1,6 @@ # 内容导入契约 -更新时间:2026-06-21 +更新时间:2026-06-29 ## 结论 @@ -33,6 +33,12 @@ POST /api/tenant-content/imports/vocabulary POST /api/tenant-content/imports/preview/handbook POST /api/tenant-content/imports/handbook +POST /api/tenant-content/imports/preview/scoreline +POST /api/tenant-content/imports/scoreline + +POST /api/tenant-content/imports/preview/videos +POST /api/tenant-content/imports/videos + GET /api/tenant-content/imports GET /api/tenant-content/imports/issues ``` @@ -144,6 +150,109 @@ GET /api/tenant-content/imports/issues 这样题目会同时落到旧兼容表和新内容导航/题目集合。 +## 分数线导入 + +分数线导入支持字段、院校、专业、年份记录一起提交,适合把旧题库地区分数线 JSON 转成新结构后统一 preview/import。 + +推荐格式: + +```json +{ + "regionId": "uuid", + "sourceName": "scoreline-tianjin-2025.json", + "fields": [ + { + "legacyId": "tj-min-score", + "fieldKey": "minScore", + "fieldName": "最低分", + "fieldType": "number", + "unit": "分", + "isFilter": true, + "isTrend": true, + "order": 1 + } + ], + "schools": [ + { + "legacyId": "school-a", + "name": "天津测试学院", + "shortName": "测试学院", + "schoolType": "public", + "isHot": true + } + ], + "majors": [ + { + "legacyId": "major-a", + "schoolLegacyId": "school-a", + "name": "软件工程" + } + ], + "records": [ + { + "legacyId": "record-a-2025", + "schoolLegacyId": "school-a", + "majorLegacyId": "major-a", + "year": 2025, + "fieldValues": { + "minScore": 188, + "planCount": 60 + } + } + ] +} +``` + +校验规则: + +- `kind` 可为 `field/school/major/record`;使用 `fields/schools/majors/records` 分桶时后端会自动补。 +- `record` 必须能通过 `schoolId`、`schoolLegacyId` 或 `schoolName` 定位院校。 +- `major` 必须能通过 `schoolId`、`schoolLegacyId` 或 `schoolName` 定位院校。 +- `fieldValues` 保存动态字段值,前端筛选和趋势图应先读取 `/api/scoreline/fields`。 +- 相同 `legacyId` 再导入会幂等更新;内容 hash 未变化时计入 `skipped`。 + +## 视频导入 + +视频导入支持视频基础信息和题目绑定一起提交。付费视频不要在列表页暴露可播放 URL,播放仍走 `POST /api/videos/play` 做权益和签名校验。 + +推荐格式: + +```json +{ + "sourceName": "question-videos.json", + "videos": [ + { + "legacyId": "video-001", + "title": "函数极限精讲", + "description": "题目解析视频", + "videoUrl": "https://example.com/private/video-001.mp4", + "thumbnailUrl": "https://example.com/thumb/video-001.jpg", + "durationSeconds": 120, + "knowledgeTags": ["高数", "极限"], + "subjectId": "uuid", + "accessMode": "svip", + "freePreviewSeconds": 15, + "bindings": [ + { + "legacyId": "video-001-question-001", + "questionId": "uuid", + "videoType": "specific", + "order": 1 + } + ] + } + ] +} +``` + +校验规则: + +- `title` 必填。 +- `accessMode` 可为 `free`、`svip`、`video_quota`。 +- 绑定题目必须提供 `questionId` 或 `legacyQuestionId`,且题目必须属于当前租户。 +- `assetId` 可以绑定 `content_assets` 台账资源;生产建议优先用资源台账和签名播放,不让前端长期持有私有视频 URL。 +- 导入成功会写 `question_videos`,并把题目 `has_video_explanation` 标记为 true。 + ## 幂等规则 - 优先使用 `legacyId` 作为跨迁移稳定标识。 @@ -154,5 +263,4 @@ GET /api/tenant-content/imports/issues ## 下一步 - 增加 Excel/CSV 解析入口,但解析后仍进入同一套 preview/import 管线。 -- 增加分数线、视频导入。 - 增加异步 worker,处理大批量导入、重试和导入后校验。 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 43edd803..6b69d78a 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -21,7 +21,7 @@ 7. `docs/refactor/multitenant-auth-security-contract.md` - 多租户、鉴权、权限、资源签名和生产安全红线。 8. `docs/refactor/content-import-contract.md` - - 后台内容导入、题目 JSON、单词、知识手册的后端校验契约。 + - 后台内容导入、题目 JSON、单词、知识手册、分数线、视频的后端校验契约。 ## 当前可进入的前端工作 @@ -43,8 +43,8 @@ - 生产鉴权已具备 Supabase JWT API 入口,自定义角色模板基础 API 已可用;仍要做真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限细化,前端不要继续使用 `x-user-id`。 - 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”;订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。 - 真实短信、微信登录、QQ 登录、微信支付、支付宝支付 provider 还未正式接完。 -- 对象存储已完成签名 provider,但 PDF 预览、防盗链、视频水印、上传后校验还要补。 -- 大批量 Excel/CSV、分数线、视频导入和异步 worker 还未完成。 +- 对象存储已完成签名 provider、上传后校验、PDF/图片预览和资源复检 worker,但 CDN 防盗链、视频水印和杀毒扫描还要补。 +- 分数线/视频 JSON 导入已可联调;大批量 Excel/CSV 解析和异步导入 worker 还未完成。 - 数据看板和分佣结算基础 API 已可联调;分佣真实打款/导出/凭证、AI 择校、主题模板市场等仍是后续商用增强项。 ## 前后端协作建议 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 0d62d256..8a53fbe2 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -1,10 +1,10 @@ # Supabase 重构功能进度矩阵 -更新时间:2026-06-21 22:52 +更新时间:2026-06-29 ## 当前结论 -当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目/单词/知识手册 JSON 批量导入基础闭环、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。 +当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目/单词/知识手册/分数线/视频 JSON 批量导入基础闭环、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。 但这还不是完整商用交付状态,也不能说旧项目核心功能已经全部重构完成。现在更准确的状态是:后端商用架构骨架已经立住,核心业务正在按模块补齐。部分功能已经有可调用 API,部分功能只有数据模型和导入映射,部分功能还没有前端/自动化测试闭环。 @@ -30,13 +30,13 @@ | 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | 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 预览渲染、资料下载前端待补 | +| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 | +| 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放和导入断言 | 播放、权益、后台绑定和批量 JSON 导入链路已实现,深度防盗链、动态水印和播放统计待补 | +| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载,assets worker 测试 | 资料资源基础闭环可跑,CDN 防盗链、杀毒扫描和资料下载前端待补 | | 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录 | 已支持部分用户资料导入 | 个人资料、目标院校/专业、会员状态、最近练习、统计聚合 API 已实现 | 核心 API 烟测 | 学生端基础个人中心已实现,签到/任务/更细统计待补 | | 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣已实现 | 核心 API 集成测试 | 基础运营后台可用,复杂活动规则、营销自动化、核销报表待补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | -| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目 JSON 导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、Excel 导入、真实对象存储签名待补 | +| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON 导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、Excel/CSV 导入和大批量异步导入待补 | | 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 | | 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/微信网页/QQ 登录生产联调待补 | | 数据导入 | 已建立 importer、risk report、validate | 已覆盖多类旧集合 | 命令行导入/校验 | `pb:import:validate` | 基础工具可用,需用真实完整数据做多轮 dry-run | @@ -139,6 +139,10 @@ tenant-content: POST /api/tenant-content/imports/vocabulary POST /api/tenant-content/imports/preview/handbook POST /api/tenant-content/imports/handbook + POST /api/tenant-content/imports/preview/scoreline + POST /api/tenant-content/imports/scoreline + POST /api/tenant-content/imports/preview/videos + POST /api/tenant-content/imports/videos GET /api/tenant-content/imports GET /api/tenant-content/imports/issues GET /api/tenant-content/videos @@ -249,7 +253,7 @@ platform-admin: 1. 正式鉴权:API 已支持 Supabase Auth JWT;生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id`、`x-platform-admin-key` 兼容入口。 2. 国内能力接入:短信、微信小程序登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备;微信网页登录、QQ 登录、真实生产账号联调、对账和支付补偿仍需实现。 3. 核心缺口 API:学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API;下一步重点是后台维护、权限、统计和真实业务验收。 -4. 后台能力:题库录入、题目/单词/知识手册 JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;Excel 导入、分数线/视频导入、真实对象存储签名和前端操作台待补。 +4. 后台能力:题库录入、题目/单词/知识手册/分数线/视频 JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;Excel/CSV 导入、大批量异步导入和前端操作台待补。 5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。 6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。 7. 运维交付:生产环境变量、备份恢复、日志监控、异常告警、数据库迁移流程、灰度发布、回滚预案。 @@ -258,9 +262,9 @@ platform-admin: 为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续: -1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,接真实 OSS/COS/Supabase Storage 签名,并把 JSON 导入扩展为异步 worker。 +1. 完善内容导入和文件上传:Excel/CSV 解析、大批量异步导入、导入后复检、CDN 防盗链、杀毒扫描,并把 JSON 导入扩展为异步 worker。 2. 补公共题库版本同步 worker、租户套餐地区/科目/题库范围限制、主题模板系统。 3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 -4. 补视频商用控制:SVIP 权限、签名 URL、防盗链、水印、播放次数扣减。 +4. 补视频商用控制:深度防盗链、动态水印和播放统计。 5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。 6. 接真实短信、微信网页/QQ 登录、退款对账和补偿任务,并开始 Taro scaffold。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index c5206a9e..1c79ea4a 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -29,7 +29,7 @@ | 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 | | 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 | | 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 | -| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 | +| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 | | 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker 和全额退款权益撤销已有;缺完整资金流水对账、异常订单运营台和前端收银台/售后体验 | | 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 | | 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载和 PDF/图片预览基础已有;缺水印、防盗链、杀毒扫描和 worker 复检 | @@ -58,13 +58,13 @@ | 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券幂等、下单抵扣、全额优惠自动开通权益已有;复杂活动规则和核销报表待补 | | 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 | | 勋章管理 | 部分覆盖 | 表结构有 badges/user_badges;缺后台和学生端 API | -| 题库录入 | 已覆盖 | 单题创建/更新、JSON 导入、集合/蓝图已有 | +| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON 导入、集合/蓝图已有;Excel/CSV 和异步大批量导入待补 | | 题库导出 PDF/Word/JSON | 未覆盖 | 旧前端有导出组件;新后端需决定服务端导出、导出水印和权限审计 | | 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 | | 背单词维护 | 已覆盖 | 单元/单词 CRUD 和导入已有 | | 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 | -| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 已有 | -| 视频维护/绑定 | 已覆盖 | video CRUD 和 question-video 绑定已有 | +| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 | +| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 | | CRM 配置和队列 | 部分覆盖 | 配置/队列已有;钉钉/飞书/企微真实发送 worker、签名、重试、死信待补 | | 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认和预览下载签名已有;租户级存储策略、CDN/水印/杀毒待补 | @@ -98,7 +98,7 @@ 1. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。 2. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。 3. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。 -4. 导入扩展:Excel/CSV、分数线、视频批量导入和大批量异步 worker。 +4. 导入扩展:分数线/视频 JSON 导入已补齐;仍缺 Excel/CSV 解析、大批量异步 worker 和导入后复检。 5. 公共题库商业化:平台公共/地区题库授权和租户快照采纳已完成基础闭环;还需版本同步、租户自改冲突处理和运营后台 UI。 6. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。 7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 @@ -116,7 +116,7 @@ 1. 微信/支付宝支付和 webhook 幂等。 2. 对象存储 PDF 预览、视频深度防盗链、动态水印。 -3. Excel/CSV、分数线、视频批量导入。 +3. Excel/CSV 解析、大批量异步导入和导入后复检。 4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。 5. 公共题库版本同步、租户采纳后的更新策略和同步 worker。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 19b6193d..1cd36a98 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -16,7 +16,7 @@ - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 - 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。 - 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。 -- 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。 +- 内容导入:题目、单词、知识手册、分数线、视频 JSON 预览、校验、导入、幂等、审计。 - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;后续补批量 CRM 推送和自动学习督导。 - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。 @@ -51,7 +51,7 @@ 3. 真实导入 dry-run - 从 PocketBase 导出现有用户、题库、单词、知识手册、分数线、订单、权益数据。 - 跑 `scripts/import-pocketbase`,生成迁移报告。 - - 对题目 JSON、单词、知识手册走后端 preview/import API 做二次验证。 + - 对题目 JSON、单词、知识手册、分数线、视频走后端 preview/import API 做二次验证。 4. 部署配置 - 整理生产 `.env` 模板。 @@ -75,8 +75,7 @@ 3. 导入体系扩展 - Excel/CSV 导入。 - - 分数线批量导入。 - - 视频批量导入和题目视频批量绑定。 + - 分数线/视频导入已完成 JSON preview/import;继续补 Excel/CSV 到规范 JSON 的解析适配。 - 大批量导入异步 worker、重试、导入后校验。 4. 公共题库和租户授权 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index d908b30b..02bdf0f0 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -685,6 +685,46 @@ content_entries - 教师可以为范围内学生创建备注和跟进任务,但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定;前端只按返回值渲染。 - H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。 +## 租户内容导入对接 + +租户后台导入统一使用 preview -> issues -> import 流程,前端不要直接写 Supabase 表或绕过 `apps/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 +POST /api/tenant-content/imports/preview/scoreline +POST /api/tenant-content/imports/scoreline +POST /api/tenant-content/imports/preview/videos +POST /api/tenant-content/imports/videos +GET /api/tenant-content/imports +GET /api/tenant-content/imports/issues +``` + +前端流程: + +1. 上传或粘贴 JSON,先调用对应 preview。 +2. 展示 `job.totalCount/validCount/errorCount/warningCount`。 +3. 展示逐行 `issues`,错误行必须让运营修正;如果后端允许 `allowPartial`,也要二次确认。 +4. 用户确认后调用 import;导入完成后刷新内容列表、分数线列表或题目视频列表。 + +分数线导入前端注意: + +- 后端支持 `fields/schools/majors/records` 分桶,也支持 `items` 列表。 +- 页面筛选字段仍以 `/api/scoreline/fields` 为准,不要从导入 JSON 临时生成筛选 UI。 +- `record` 至少需要 `schoolId`、`schoolLegacyId` 或 `schoolName`,否则 preview 会返回 issue。 + +视频导入前端注意: + +- 列表和搜索接口不会给付费视频可播放 URL;播放统一调 `POST /api/videos/play`。 +- 绑定题目必须提供 `questionId` 或 `legacyQuestionId`。 +- 生产建议把私有视频先入 `content_assets`,导入时传 `assetId`,避免长期暴露源站 URL。 + ## 公共题库采纳对接 平台超级管理员后台使用: @@ -1015,7 +1055,7 @@ ACTIVATION_CODE_REGION_MISMATCH - 角色模板:`GET/PUT /api/tenant-admin/role-templates`、`POST /api/tenant-admin/role-templates/disable` - 内容入口/分类树/题目集合/练习蓝图 - 题目/单词/知识手册/分数线/视频维护 -- JSON 导入 preview/import/issues +- 题目/单词/知识手册/分数线/视频 JSON 导入 preview/import/issues - Banner/FAQ/公告/激活码/优惠券 - 考试日期:`GET/PUT /api/tenant-admin/exam-dates` - 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index c19cb68d..b2910c51 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -3012,6 +3012,195 @@ async function testTenantContentAssetsAndImports() { 'catalog should expose imported handbook entry content', ); + const invalidScorelinePreview = await request('/api/tenant-content/imports/preview/scoreline', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + regionId: ids.region, + items: [{ kind: 'record', year: 2026, score: 180 }], + }, + }); + assert.ok( + invalidScorelinePreview.issues?.some(issue => issue.code === 'SCORELINE_RECORD_SCHOOL_REQUIRED'), + 'invalid scoreline preview should validate school reference', + ); + + const scorelinePreview = await request('/api/tenant-content/imports/preview/scoreline', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + sourceName: 'scoreline-batch-import.json', + regionId: ids.region, + fields: [ + { + legacyId: 'integration-scoreline-field-min', + fieldKey: 'minScore', + fieldName: '最低分', + fieldType: 'number', + unit: '分', + isFilter: true, + isTrend: true, + order: 1, + }, + ], + schools: [ + { + legacyId: 'integration-scoreline-school-a', + name: '集成测试学院', + shortName: '集测学院', + type: 'public', + isHot: true, + }, + ], + majors: [ + { + legacyId: 'integration-scoreline-major-a', + schoolLegacyId: 'integration-scoreline-school-a', + name: '软件工程', + hasRestriction: false, + }, + ], + records: [ + { + legacyId: 'integration-scoreline-record-a-2025', + schoolLegacyId: 'integration-scoreline-school-a', + majorLegacyId: 'integration-scoreline-major-a', + schoolName: '集成测试学院', + majorName: '软件工程', + year: 2025, + fieldValues: { minScore: 188, planCount: 60 }, + }, + ], + }, + }); + assert.equal(scorelinePreview.job?.errorCount, 0, 'valid scoreline preview should have no errors'); + assert.equal(scorelinePreview.job?.validCount, 4, 'scoreline preview should normalize fields, schools, majors, and records'); + + const scorelineImport = await request('/api/tenant-content/imports/scoreline', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { previewJobId: scorelinePreview.job.id }, + }); + assert.equal(scorelineImport.item?.status, 'completed', 'scoreline import should complete'); + assert.ok( + (scorelineImport.item?.insertedCount || 0) + (scorelineImport.item?.updatedCount || 0) + (scorelineImport.item?.skippedCount || 0) >= 4, + 'scoreline import should process all valid rows', + ); + + const importedScorelineRecords = await request('/api/scoreline/records', { + query: { regionId: ids.region, year: 2025, pageSize: 50 }, + }); + assert.ok( + importedScorelineRecords.items?.some(item => item.schoolName === '集成测试学院' && item.fieldValues?.minScore === 188), + 'public scoreline records should expose imported record', + ); + + const mixedScorelinePreview = await request('/api/tenant-content/imports/preview/scoreline', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + sourceName: 'scoreline-mixed-items.json', + regionId: ids.region, + items: [ + { + kind: 'record', + legacyId: 'integration-scoreline-mixed-record-2026', + schoolLegacyId: 'integration-scoreline-mixed-school', + majorLegacyId: 'integration-scoreline-mixed-major', + year: 2026, + minScore: 211, + }, + { + kind: 'major', + legacyId: 'integration-scoreline-mixed-major', + schoolLegacyId: 'integration-scoreline-mixed-school', + name: '混排专业', + }, + { + kind: 'school', + legacyId: 'integration-scoreline-mixed-school', + name: '混排测试学院', + }, + ], + }, + }); + assert.equal(mixedScorelinePreview.job?.errorCount, 0, 'mixed scoreline preview should accept out-of-order items'); + + const mixedScorelineImport = await request('/api/tenant-content/imports/scoreline', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { previewJobId: mixedScorelinePreview.job.id }, + }); + assert.equal(mixedScorelineImport.item?.status, 'completed', 'mixed scoreline import should order dependent rows'); + + const mixedScorelineRecords = await request('/api/scoreline/records', { + query: { regionId: ids.region, year: 2026, pageSize: 50 }, + }); + assert.ok( + mixedScorelineRecords.items?.some(item => item.schoolName === '混排测试学院' && item.fieldValues?.minScore === 211), + 'mixed scoreline import should create record after school and major', + ); + + const invalidVideoPreview = await request('/api/tenant-content/imports/preview/videos', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + videos: [{ title: '缺少题目绑定', bindings: [{ videoType: 'specific' }] }], + }, + }); + assert.ok( + invalidVideoPreview.issues?.some(issue => issue.code === 'VIDEO_BINDING_QUESTION_REQUIRED'), + 'invalid video preview should validate question binding', + ); + + const videoPreview = await request('/api/tenant-content/imports/preview/videos', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + sourceName: 'video-batch-import.json', + videos: [ + { + legacyId: 'integration-video-import-001', + title: '批量导入视频精讲', + description: '通过内容导入管线导入并绑定题目', + videoUrl: 'https://example.test/videos/imported.mp4', + thumbnailUrl: 'https://example.test/videos/imported.jpg', + durationSeconds: 120, + knowledgeTags: ['批量导入', '视频精讲'], + isGeneral: false, + subjectId: ids.subject, + accessMode: 'free', + bindings: [ + { + legacyId: 'integration-video-binding-001', + questionId: ids.question, + videoType: 'specific', + order: 1, + }, + ], + }, + ], + }, + }); + assert.equal(videoPreview.job?.errorCount, 0, 'valid video preview should have no errors'); + + const videoImport = await request('/api/tenant-content/imports/videos', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { previewJobId: videoPreview.job.id }, + }); + assert.equal(videoImport.item?.status, 'completed', 'video import should complete'); + assert.ok( + (videoImport.item?.insertedCount || 0) + (videoImport.item?.updatedCount || 0) + (videoImport.item?.skippedCount || 0) >= 2, + 'video import should process video and question binding', + ); + + const questionVideosAfterImport = await request(`/api/questions/${ids.question}/videos`); + assert.ok( + questionVideosAfterImport.videos?.some(item => item.legacyId === 'integration-video-import-001' && item.title === '批量导入视频精讲'), + 'question video API should expose imported video binding', + ); + const importJobs = await request('/api/tenant-content/imports', { userId: TENANT_ADMIN_USER_ID, query: { importType: 'vocabulary', limit: 10 }, diff --git a/scripts/asset-worker-integration-test.js b/scripts/asset-worker-integration-test.js index 8afebfb9..5f12efbe 100644 --- a/scripts/asset-worker-integration-test.js +++ b/scripts/asset-worker-integration-test.js @@ -41,6 +41,11 @@ async function runWorkerOnce() { return output; } +function countFromWorkerOutput(output, name) { + const match = output.match(new RegExp(`${name}=(\\d+)`)); + return match ? Number(match[1]) : 0; +} + async function cleanup(pool) { await pool.query( ` @@ -109,8 +114,8 @@ async function main() { seeded = true; const output = await runWorkerOnce(); - assert.match(output, /verified=1/, 'worker should verify one asset'); - assert.match(output, /failed=1/, 'worker should fail one mismatched asset'); + assert.ok(countFromWorkerOutput(output, 'verified') >= 1, 'worker should verify at least one asset'); + assert.ok(countFromWorkerOutput(output, 'failed') >= 1, 'worker should fail at least one mismatched asset'); const assets = await pool.query( ` diff --git a/supabase/migrations/202606290012_scoreline_video_import_metadata.sql b/supabase/migrations/202606290012_scoreline_video_import_metadata.sql new file mode 100644 index 00000000..56049187 --- /dev/null +++ b/supabase/migrations/202606290012_scoreline_video_import_metadata.sql @@ -0,0 +1,42 @@ +alter table public.video_explanations + add column if not exists source_hash text, + add column if not exists metadata jsonb not null default '{}'::jsonb; + +alter table public.question_videos + add column if not exists metadata jsonb not null default '{}'::jsonb; + +alter table public.scoreline_fields + add column if not exists source_hash text, + add column if not exists metadata jsonb not null default '{}'::jsonb; + +alter table public.scoreline_schools + add column if not exists source_hash text, + add column if not exists metadata jsonb not null default '{}'::jsonb; + +alter table public.scoreline_majors + add column if not exists source_hash text, + add column if not exists metadata jsonb not null default '{}'::jsonb; + +alter table public.scoreline_records + 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_video_explanations_source_hash + on public.video_explanations(tenant_id, source_hash) + where source_hash is not null; + +create index if not exists idx_scoreline_fields_source_hash + on public.scoreline_fields(tenant_id, source_hash) + where source_hash is not null; + +create index if not exists idx_scoreline_schools_source_hash + on public.scoreline_schools(tenant_id, source_hash) + where source_hash is not null; + +create index if not exists idx_scoreline_majors_source_hash + on public.scoreline_majors(tenant_id, source_hash) + where source_hash is not null; + +create index if not exists idx_scoreline_records_source_hash + on public.scoreline_records(tenant_id, source_hash) + where source_hash is not null;