feat: add import post-check templates

This commit is contained in:
Codex
2026-06-29 08:16:48 +08:00
parent eee21f6eed
commit 69f8e28d4e
15 changed files with 954 additions and 28 deletions

View File

@@ -28,7 +28,7 @@
- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。 - Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配微信网页登录、QQ 登录、手机号换绑、完整资金流水对账和真实生产账号联调还没接完。 - 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 worker 已完成本地适配微信网页登录、QQ 登录、手机号换绑、完整资金流水对账和真实生产账号联调还没接完。
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF/图片预览和资源复检 worker 已完成CDN 防盗链、杀毒扫描和视频动态水印还没完成。 - OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF/图片预览和资源复检 worker 已完成CDN 防盗链、杀毒扫描和视频动态水印还没完成。
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检模板下载/字段映射 UI 还没完成 - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检模板下载字段映射 API 已完成,前端 UI 待接
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。 - 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警和销售转化看板还没完成。
- Taro 跨端前端还没开始 scaffold。 - Taro 跨端前端还没开始 scaffold。
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
@@ -213,5 +213,5 @@ npm run check:refactor
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
2. Taro 前端 scaffold让 H5 和小程序共用同一套 API。 2. Taro 前端 scaffold让 H5 和小程序共用同一套 API。
3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。 3. 对象存储 CDN 防盗链、杀毒扫描、视频动态水印和生命周期策略。
4. 导入后复检、题库导出 PDF/Word/JSON、模板下载和字段映射 UI 4. 题库导出 PDF/Word/JSON、真实数据 dry-run、导入字段映射 UI 和复检结果操作台
5. 微信网页/QQ 登录、完整资金流水对账、公共题库版本同步 worker、积分活动深化以及排行榜防刷/预聚合。 5. 微信网页/QQ 登录、完整资金流水对账、公共题库版本同步 worker、积分活动深化以及排行榜防刷/预聚合。

View File

@@ -0,0 +1,467 @@
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { queryOne, transaction } from '../../core/db.js';
import { readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
type ImportType = 'questions' | 'vocabulary' | 'handbook' | 'scoreline' | 'videos';
interface ImportJobForPostCheck {
id: string;
importType: ImportType;
status: string;
totalCount: number;
validCount: number;
errorCount: number;
warningCount: number;
targetCollectionId: string | null;
summary: Record<string, unknown>;
}
interface PostCheckIssue {
severity: 'error' | 'warning';
code: string;
message: string;
details?: Record<string, unknown>;
}
interface PostCheckSummary {
status: 'passed' | 'warning' | 'failed';
checkedAt: string;
jobId: string;
importType: ImportType;
counts: Record<string, number>;
issues: PostCheckIssue[];
}
async function loadCompletedJob(client: pg.PoolClient, auth: TenantContentAuth, jobId: string) {
const job = await client.query<ImportJobForPostCheck>(
`
select id, import_type as "importType", status,
total_count as "totalCount", valid_count as "validCount",
error_count as "errorCount", warning_count as "warningCount",
target_collection_id as "targetCollectionId",
summary
from public.content_import_jobs
where tenant_id = $1 and id = $2
limit 1
`,
[auth.tenantId, jobId],
);
const item = job.rows[0];
if (!item) throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
if (!['completed', 'completed_with_errors'].includes(item.status)) {
throw new HttpError(409, 'Import job is not completed yet', 'IMPORT_JOB_NOT_COMPLETED');
}
return item;
}
async function baseCounts(client: pg.PoolClient, tenantId: string, jobId: string) {
const result = await client.query<{
item_count: string;
valid_item_count: string;
processed_item_count: string;
target_missing_count: string;
target_id_count: string;
inserted_count: string;
updated_count: string;
skipped_count: string;
failed_count: string;
}>(
`
select count(*)::text as item_count,
count(*) filter (where status in ('valid', 'inserted', 'updated', 'skipped'))::text as valid_item_count,
count(*) filter (where status in ('inserted', 'updated', 'skipped'))::text as processed_item_count,
count(*) filter (where status in ('inserted', 'updated', 'skipped') and target_id is null)::text as target_missing_count,
count(target_id)::text as target_id_count,
count(*) filter (where status = 'inserted')::text as inserted_count,
count(*) filter (where status = 'updated')::text as updated_count,
count(*) filter (where status = 'skipped')::text as skipped_count,
count(*) filter (where status = 'failed')::text as failed_count
from public.content_import_items
where tenant_id = $1 and job_id = $2
`,
[tenantId, jobId],
);
const row = result.rows[0];
return {
itemCount: Number(row?.item_count || 0),
validItemCount: Number(row?.valid_item_count || 0),
processedItemCount: Number(row?.processed_item_count || 0),
targetMissingCount: Number(row?.target_missing_count || 0),
targetIdCount: Number(row?.target_id_count || 0),
insertedCount: Number(row?.inserted_count || 0),
updatedCount: Number(row?.updated_count || 0),
skippedCount: Number(row?.skipped_count || 0),
failedCount: Number(row?.failed_count || 0),
};
}
function addBaseIssues(job: ImportJobForPostCheck, counts: Record<string, number>, issues: PostCheckIssue[]) {
if (counts.itemCount !== job.totalCount) {
issues.push({
severity: 'warning',
code: 'IMPORT_ITEM_COUNT_MISMATCH',
message: 'Import item count does not match job totalCount',
details: { itemCount: counts.itemCount, totalCount: job.totalCount },
});
}
if (counts.processedItemCount !== job.validCount) {
issues.push({
severity: 'error',
code: 'IMPORT_PROCESSED_COUNT_MISMATCH',
message: 'Processed import item count does not match validCount',
details: { processedItemCount: counts.processedItemCount, validCount: job.validCount },
});
}
if (counts.targetMissingCount > 0) {
issues.push({
severity: 'error',
code: 'IMPORT_TARGET_ID_MISSING',
message: 'Some processed import items do not have a target_id',
details: { targetMissingCount: counts.targetMissingCount },
});
}
if (counts.failedCount > 0) {
issues.push({
severity: 'error',
code: 'IMPORT_ITEM_FAILED',
message: 'Some import items are marked failed',
details: { failedCount: counts.failedCount },
});
}
if (job.errorCount > 0 && job.status === 'completed_with_errors') {
issues.push({
severity: 'warning',
code: 'IMPORT_COMPLETED_WITH_VALIDATION_ERRORS',
message: 'Import completed with validation errors because allowPartial was used',
details: { errorCount: job.errorCount },
});
}
}
async function countExistingTargets(client: pg.PoolClient, tenantId: string, jobId: string, tableName: string) {
const result = await client.query<{ existing_count: string }>(
`
select count(distinct item.target_id)::text as existing_count
from public.content_import_items item
join ${tableName} target on target.tenant_id = item.tenant_id and target.id = item.target_id
where item.tenant_id = $1
and item.job_id = $2
and item.status in ('inserted', 'updated', 'skipped')
and item.target_id is not null
`,
[tenantId, jobId],
);
return Number(result.rows[0]?.existing_count || 0);
}
async function questionChecks(client: pg.PoolClient, auth: TenantContentAuth, job: ImportJobForPostCheck, counts: Record<string, number>) {
const issues: PostCheckIssue[] = [];
const existingTargetCount = await countExistingTargets(client, auth.tenantId, job.id, 'public.questions');
counts.existingTargetCount = existingTargetCount;
const versions = await client.query<{ count: string }>(
`
select count(distinct q.id)::text as count
from public.content_import_items item
join public.questions q on q.tenant_id = item.tenant_id and q.id = item.target_id
join public.question_versions v on v.tenant_id = q.tenant_id and v.id = q.current_version_id and v.question_id = q.id
where item.tenant_id = $1
and item.job_id = $2
and item.status in ('inserted', 'updated', 'skipped')
`,
[auth.tenantId, job.id],
);
counts.questionWithCurrentVersionCount = Number(versions.rows[0]?.count || 0);
if (counts.existingTargetCount !== counts.processedItemCount) {
issues.push({
severity: 'error',
code: 'QUESTION_TARGET_MISSING',
message: 'Some imported question targets are missing',
details: { existingTargetCount: counts.existingTargetCount, processedItemCount: counts.processedItemCount },
});
}
if (counts.questionWithCurrentVersionCount !== counts.processedItemCount) {
issues.push({
severity: 'error',
code: 'QUESTION_VERSION_MISSING',
message: 'Some imported questions do not have a current version',
details: { questionWithCurrentVersionCount: counts.questionWithCurrentVersionCount, processedItemCount: counts.processedItemCount },
});
}
if (job.targetCollectionId) {
const collectionItems = await client.query<{ count: string }>(
`
select count(distinct item.target_id)::text as count
from public.content_import_items item
join public.question_collection_items qci
on qci.tenant_id = item.tenant_id
and qci.question_id = item.target_id
and qci.collection_id = $3
where item.tenant_id = $1
and item.job_id = $2
and item.status in ('inserted', 'updated', 'skipped')
`,
[auth.tenantId, job.id, job.targetCollectionId],
);
counts.collectionBindingCount = Number(collectionItems.rows[0]?.count || 0);
if (counts.collectionBindingCount !== counts.processedItemCount) {
issues.push({
severity: 'error',
code: 'QUESTION_COLLECTION_BINDING_MISSING',
message: 'Some imported questions are not bound to the target collection',
details: { collectionBindingCount: counts.collectionBindingCount, processedItemCount: counts.processedItemCount, collectionId: job.targetCollectionId },
});
}
}
return issues;
}
async function vocabularyChecks(client: pg.PoolClient, auth: TenantContentAuth, job: ImportJobForPostCheck, counts: Record<string, number>) {
const issues: PostCheckIssue[] = [];
counts.existingTargetCount = await countExistingTargets(client, auth.tenantId, job.id, 'public.vocabulary_units');
const words = await client.query<{ count: string }>(
`
select count(w.id)::text as count
from public.content_import_items item
join public.vocabulary_units unit on unit.tenant_id = item.tenant_id and unit.id = item.target_id
left join public.vocabulary_words w on w.tenant_id = unit.tenant_id and w.unit_id = unit.id
where item.tenant_id = $1
and item.job_id = $2
and item.status in ('inserted', 'updated', 'skipped')
`,
[auth.tenantId, job.id],
);
counts.vocabularyWordCount = Number(words.rows[0]?.count || 0);
if (counts.existingTargetCount !== counts.processedItemCount) {
issues.push({
severity: 'error',
code: 'VOCABULARY_UNIT_TARGET_MISSING',
message: 'Some imported vocabulary units are missing',
details: { existingTargetCount: counts.existingTargetCount, processedItemCount: counts.processedItemCount },
});
}
if (counts.processedItemCount > 0 && counts.vocabularyWordCount <= 0) {
issues.push({ severity: 'error', code: 'VOCABULARY_WORDS_MISSING', message: 'Imported vocabulary units have no words' });
}
return issues;
}
async function handbookChecks(client: pg.PoolClient, auth: TenantContentAuth, job: ImportJobForPostCheck, counts: Record<string, number>) {
const issues: PostCheckIssue[] = [];
counts.existingTargetCount = await countExistingTargets(client, auth.tenantId, job.id, 'public.handbook_subjects');
const children = await client.query<{ chapter_count: string; entry_count: string }>(
`
select count(distinct c.id)::text as chapter_count,
count(distinct e.id)::text as entry_count
from public.content_import_items item
join public.handbook_subjects subject on subject.tenant_id = item.tenant_id and subject.id = item.target_id
left join public.handbook_chapters c on c.tenant_id = subject.tenant_id and c.subject_id = subject.id
left join public.handbook_entries e on e.tenant_id = c.tenant_id and e.chapter_id = c.id
where item.tenant_id = $1
and item.job_id = $2
and item.status in ('inserted', 'updated', 'skipped')
`,
[auth.tenantId, job.id],
);
counts.handbookChapterCount = Number(children.rows[0]?.chapter_count || 0);
counts.handbookEntryCount = Number(children.rows[0]?.entry_count || 0);
if (counts.existingTargetCount !== counts.processedItemCount) {
issues.push({
severity: 'error',
code: 'HANDBOOK_SUBJECT_TARGET_MISSING',
message: 'Some imported handbook subjects are missing',
details: { existingTargetCount: counts.existingTargetCount, processedItemCount: counts.processedItemCount },
});
}
if (counts.processedItemCount > 0 && counts.handbookEntryCount <= 0) {
issues.push({ severity: 'warning', code: 'HANDBOOK_ENTRIES_EMPTY', message: 'Imported handbook subjects do not contain entries' });
}
return issues;
}
async function scorelineChecks(client: pg.PoolClient, auth: TenantContentAuth, job: ImportJobForPostCheck, counts: Record<string, number>) {
const kinds = await client.query<{ kind: string; processed_count: string; existing_count: string }>(
`
with processed as (
select target_id, normalized_payload ->> 'kind' as kind
from public.content_import_items
where tenant_id = $1
and job_id = $2
and status in ('inserted', 'updated', 'skipped')
)
select 'field' as kind,
count(*) filter (where p.kind = 'field')::text as processed_count,
count(f.id)::text as existing_count
from processed p
left join public.scoreline_fields f on p.kind = 'field' and f.tenant_id = $1 and f.id = p.target_id
union all
select 'school',
count(*) filter (where p.kind = 'school')::text,
count(s.id)::text
from processed p
left join public.scoreline_schools s on p.kind = 'school' and s.tenant_id = $1 and s.id = p.target_id
union all
select 'major',
count(*) filter (where p.kind = 'major')::text,
count(m.id)::text
from processed p
left join public.scoreline_majors m on p.kind = 'major' and m.tenant_id = $1 and m.id = p.target_id
union all
select 'record',
count(*) filter (where p.kind = 'record')::text,
count(r.id)::text
from processed p
left join public.scoreline_records r on p.kind = 'record' and r.tenant_id = $1 and r.id = p.target_id
`,
[auth.tenantId, job.id],
);
const issues: PostCheckIssue[] = [];
for (const row of kinds.rows) {
const processed = Number(row.processed_count || 0);
const existing = Number(row.existing_count || 0);
counts[`scoreline${row.kind[0].toUpperCase()}${row.kind.slice(1)}ProcessedCount`] = processed;
counts[`scoreline${row.kind[0].toUpperCase()}${row.kind.slice(1)}ExistingCount`] = existing;
if (processed !== existing) {
issues.push({
severity: 'error',
code: 'SCORELINE_TARGET_MISSING',
message: `Some imported scoreline ${row.kind} targets are missing`,
details: { kind: row.kind, processed, existing },
});
}
}
counts.existingTargetCount = kinds.rows.reduce((sum, row) => sum + Number(row.existing_count || 0), 0);
return issues;
}
async function videoChecks(client: pg.PoolClient, auth: TenantContentAuth, job: ImportJobForPostCheck, counts: Record<string, number>) {
const issues: PostCheckIssue[] = [];
counts.existingTargetCount = await countExistingTargets(client, auth.tenantId, job.id, 'public.video_explanations');
const bindings = await client.query<{ count: string }>(
`
select count(qv.id)::text as count
from public.content_import_items item
join public.video_explanations video on video.tenant_id = item.tenant_id and video.id = item.target_id
left join public.question_videos qv on qv.tenant_id = video.tenant_id and qv.video_id = video.id
where item.tenant_id = $1
and item.job_id = $2
and item.status in ('inserted', 'updated', 'skipped')
`,
[auth.tenantId, job.id],
);
counts.videoBindingCount = Number(bindings.rows[0]?.count || 0);
if (counts.existingTargetCount !== counts.processedItemCount) {
issues.push({
severity: 'error',
code: 'VIDEO_TARGET_MISSING',
message: 'Some imported videos are missing',
details: { existingTargetCount: counts.existingTargetCount, processedItemCount: counts.processedItemCount },
});
}
if (counts.processedItemCount > 0 && counts.videoBindingCount <= 0) {
issues.push({ severity: 'warning', code: 'VIDEO_BINDINGS_EMPTY', message: 'Imported videos do not have question bindings' });
}
return issues;
}
async function domainChecks(client: pg.PoolClient, auth: TenantContentAuth, job: ImportJobForPostCheck, counts: Record<string, number>) {
switch (job.importType) {
case 'questions':
return questionChecks(client, auth, job, counts);
case 'vocabulary':
return vocabularyChecks(client, auth, job, counts);
case 'handbook':
return handbookChecks(client, auth, job, counts);
case 'scoreline':
return scorelineChecks(client, auth, job, counts);
case 'videos':
return videoChecks(client, auth, job, counts);
default:
return [];
}
}
function finalStatus(issues: PostCheckIssue[]): PostCheckSummary['status'] {
if (issues.some(issue => issue.severity === 'error')) return 'failed';
if (issues.some(issue => issue.severity === 'warning')) return 'warning';
return 'passed';
}
async function runPostCheck(auth: TenantContentAuth, jobId: string) {
return transaction(async client => {
const job = await loadCompletedJob(client, auth, jobId);
const counts = await baseCounts(client, auth.tenantId, job.id);
const issues: PostCheckIssue[] = [];
addBaseIssues(job, counts, issues);
issues.push(...await domainChecks(client, auth, job, counts));
const summary: PostCheckSummary = {
status: finalStatus(issues),
checkedAt: new Date().toISOString(),
jobId: job.id,
importType: job.importType,
counts,
issues,
};
await client.query(
`
update public.content_import_jobs
set summary = coalesce(summary, '{}'::jsonb) || $3::jsonb,
updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, job.id, JSON.stringify({ importPostCheck: summary })],
);
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, 'content_import_job', $4, $5::jsonb)
`,
[
auth.tenantId,
auth.userId,
`content.import.${job.importType}.post_checked`,
job.id,
JSON.stringify({ status: summary.status, issueCount: summary.issues.length, counts: summary.counts }),
],
);
return summary;
});
}
export async function importPostCheckRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const jobId = requiredString(body, 'jobId');
const item = await runPostCheck(auth, jobId);
return { item };
}
export async function importPostCheckStatusRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const jobId = stringParam(ctx, 'jobId');
if (!jobId) throw new HttpError(400, 'jobId is required', 'REQUIRED_FIELD');
const job = await queryOne<{ id: string; summary: Record<string, unknown> }>(
'select id, summary from public.content_import_jobs where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, jobId],
);
if (!job) throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
return {
item: {
jobId: job.id,
importPostCheck: job.summary?.importPostCheck || null,
},
};
}

View File

@@ -0,0 +1,284 @@
import { Buffer } from 'node:buffer';
import { HttpError, type RequestContext } from '../../core/http.js';
import { stringParam } from '../../core/request.js';
import { requireTenantContentEditor } from './auth.js';
type ImportType = 'questions' | 'vocabulary' | 'handbook' | 'scoreline' | 'videos';
type TemplateFormat = 'json' | 'csv';
interface FieldSpec {
field: string;
label: string;
required: boolean;
aliases: string[];
description: string;
example: unknown;
}
interface ImportTemplateSpec {
importType: ImportType;
title: string;
description: string;
fields: FieldSpec[];
csvRows: string[][];
jsonExample: unknown;
}
const IMPORT_TYPES = new Set<ImportType>(['questions', 'vocabulary', 'handbook', 'scoreline', 'videos']);
const FORMATS = new Set<TemplateFormat>(['json', 'csv']);
const questionFields: FieldSpec[] = [
{ field: 'legacyId', label: '旧系统ID/外部ID', required: false, aliases: ['legacy_id', 'externalId', 'external_id', 'id'], description: '用于幂等更新,同一租户内重复导入会更新或跳过。', example: 'tj-english-2026-001' },
{ field: 'type', label: '题型', required: true, aliases: ['题型', 'questionType'], description: '支持 choice、multi、judge、reading、short_answer 等新题型编码。', example: 'choice' },
{ field: 'typeLabel', label: '题型显示名', required: false, aliases: ['type_label', '题型名称'], description: '用于保留旧卷面上的题型名称。', example: '单选题' },
{ field: 'content', label: '题干', required: true, aliases: ['题干', 'stem', 'question'], description: '支持 Markdown、KaTeX、图片 URL 和表格。', example: '多租户 SaaS 最重要的安全边界是什么?' },
{ field: 'options', label: '选项', required: false, aliases: ['选项', 'choices', '选项A/选项B'], description: '客观题必填CSV 可使用选项A、选项B 等列。', example: ['前端隐藏', '后端权限和 RLS'] },
{ field: 'correctOptionIndices', label: '正确选项索引', required: false, aliases: ['correct_option_indices', '答案', 'answer'], description: '客观题答案,从 0 开始CSV 也支持 A/B/C/D。', example: [1] },
{ field: 'answerText', label: '文字答案', required: false, aliases: ['answer_text', '主观题答案'], description: '填空、简答、论述等主观题使用。', example: '以后端权限和数据库 RLS 为准。' },
{ field: 'explanation', label: '解析', required: false, aliases: ['解析', 'analysis'], description: '题目解析内容。', example: '前端只做展示,最终权限必须由后端强制。' },
{ field: 'difficulty', label: '难度', required: false, aliases: ['难度'], description: '建议 1-5。', example: 2 },
{ field: 'tags', label: '标签', required: false, aliases: ['标签', 'tag'], description: 'JSON 数组或 CSV 中用 | 分隔。', example: ['安全', '多租户'] },
];
const vocabularyFields: FieldSpec[] = [
{ field: 'unitLegacyId', label: '单元外部ID', required: false, aliases: ['unit_legacy_id', 'unitId'], description: '单词单元幂等键。', example: 'tj-english-core-001' },
{ field: 'unitName', label: '单元名称', required: true, aliases: ['unit', '单元', 'name'], description: '单词所属单元名称。', example: '核心词汇 Unit 1' },
{ field: 'wordLegacyId', label: '单词外部ID', required: false, aliases: ['word_legacy_id', 'legacyId'], description: '单词幂等键。', example: 'word-scale' },
{ field: 'word', label: '单词', required: true, aliases: ['单词'], description: '英文单词或词组。', example: 'scale' },
{ field: 'phonetic', label: '音标', required: false, aliases: ['音标'], description: '音标展示文本。', example: '/skeil/' },
{ field: 'meaning', label: '释义', required: true, aliases: ['释义', '中文'], description: '中文释义。', example: 'n. 规模;等级' },
{ field: 'example', label: '例句', required: false, aliases: ['例句'], description: '英文例句。', example: 'The platform must scale safely.' },
{ field: 'difficulty', label: '难度', required: false, aliases: ['难度'], description: '建议 1-5。', example: 2 },
{ field: 'tags', label: '标签', required: false, aliases: ['标签'], description: 'JSON 数组或 CSV 中用 | 分隔。', example: ['高频', 'SaaS'] },
];
const handbookFields: FieldSpec[] = [
{ field: 'subjectName', label: '手册科目', required: true, aliases: ['subject', '手册', 'bookName'], description: '知识手册顶层名称。', example: '专升本英语知识手册' },
{ field: 'chapterName', label: '章节', required: true, aliases: ['chapter', '章节'], description: '章节名称。', example: '第一章 语法基础' },
{ field: 'sectionName', label: '小节', required: false, aliases: ['section', '小节'], description: '可选小节名称。', example: '名词性从句' },
{ field: 'title', label: '知识点标题', required: true, aliases: ['entryTitle', '标题'], description: '知识点条目标题。', example: 'that 引导的主语从句' },
{ field: 'content', label: '正文', required: true, aliases: ['正文', 'markdown'], description: 'Markdown 正文,允许图片和公式。', example: '主语从句可放在句首,也可用 it 作形式主语。' },
{ field: 'summary', label: '摘要', required: false, aliases: ['摘要'], description: '条目摘要。', example: '主语从句核心用法。' },
{ field: 'tags', label: '标签', required: false, aliases: ['标签'], description: 'JSON 数组或 CSV 中用 | 分隔。', example: ['语法'] },
];
const scorelineFields: FieldSpec[] = [
{ field: 'kind', label: '数据类型', required: true, aliases: ['type', '类型'], description: 'field、school、major、record。', example: 'record' },
{ field: 'legacyId', label: '外部ID', required: false, aliases: ['legacy_id', 'externalId'], description: '幂等键。', example: 'tj-score-2026-school-major' },
{ field: 'regionId', label: '地区ID', required: false, aliases: ['region_id'], description: '可由导入目标地区补齐。', example: '00000000-0000-0000-0000-000000000301' },
{ field: 'schoolName', label: '院校名称', required: false, aliases: ['school', '院校'], description: 'school、major、record 常用。', example: '天津职业技术师范大学' },
{ field: 'majorName', label: '专业名称', required: false, aliases: ['major', '专业'], description: 'major、record 常用。', example: '软件工程' },
{ field: 'year', label: '年份', required: false, aliases: ['年份'], description: 'record 必填。', example: 2026 },
{ field: 'fieldKey', label: '动态字段键', required: false, aliases: ['field_key'], description: 'field 必填,例如 minScore。', example: 'minScore' },
{ field: 'fieldName', label: '动态字段名', required: false, aliases: ['field_name'], description: 'field 展示名称。', example: '最低分' },
{ field: 'fieldValues', label: '分数线字段值', required: false, aliases: ['values', '分数字段'], description: 'record 的动态字段 JSONCSV 支持额外列。', example: { minScore: 188 } },
];
const videoFields: FieldSpec[] = [
{ field: 'legacyId', label: '视频外部ID', required: false, aliases: ['legacy_id', 'externalId'], description: '视频幂等键。', example: 'video-question-001' },
{ field: 'title', label: '标题', required: true, aliases: ['视频标题', 'name'], description: '视频标题。', example: '多租户隔离题解析' },
{ field: 'videoUrl', label: '视频 URL', required: false, aliases: ['video_url', 'url'], description: '外部视频 URL商用建议改用 content_assets。', example: 'https://cdn.example.test/video.mp4' },
{ field: 'assetId', label: '资源ID', required: false, aliases: ['asset_id'], description: '对象存储资源台账 ID。', example: '00000000-0000-0000-0000-000000000000' },
{ field: 'accessMode', label: '访问模式', required: false, aliases: ['access_mode'], description: 'free、svip、video_quota。', example: 'video_quota' },
{ field: 'questionId', label: '题目ID', required: false, aliases: ['question_id'], description: '绑定题目 ID。', example: '00000000-0000-0000-0000-000000000401' },
{ field: 'legacyQuestionId', label: '题目外部ID', required: false, aliases: ['legacy_question_id'], description: '按旧题目 ID 绑定。', example: 'tj-english-2026-001' },
{ field: 'videoType', label: '视频类型', required: false, aliases: ['video_type'], description: 'specific、general 等。', example: 'specific' },
];
const SPECS: Record<ImportType, ImportTemplateSpec> = {
questions: {
importType: 'questions',
title: '题目导入模板',
description: '用于导入刷题题库,后端会校验题型、答案、目标科目/分类/集合和租户隔离。',
fields: questionFields,
csvRows: [
['legacyId', '题型', '题干', '选项A', '选项B', '选项C', '选项D', '答案', '解析', '难度', '标签'],
['tj-english-2026-001', 'choice', '多租户 SaaS 最重要的安全边界是什么?', '前端隐藏菜单', '后端权限和 RLS', '浏览器缓存', '静态页面', 'B', '最终权限以后端和 RLS 为准。', '2', '安全|多租户'],
],
jsonExample: {
items: [
{
legacyId: 'tj-english-2026-001',
type: 'choice',
typeLabel: '单选题',
content: '多租户 SaaS 最重要的安全边界是什么?',
options: ['前端隐藏菜单', '后端权限和 RLS', '浏览器缓存', '静态页面'],
correctOptionIndices: [1],
explanation: '最终权限以后端和 RLS 为准。',
difficulty: 2,
tags: ['安全', '多租户'],
},
],
},
},
vocabulary: {
importType: 'vocabulary',
title: '单词导入模板',
description: '用于导入背单词单元和单词,后端会按单元归组并幂等写入。',
fields: vocabularyFields,
csvRows: [
['unitLegacyId', 'unitName', 'wordLegacyId', 'word', 'phonetic', 'meaning', 'example', 'difficulty', 'tags'],
['tj-english-core-001', '核心词汇 Unit 1', 'word-scale', 'scale', '/skeil/', 'n. 规模;等级', 'The platform must scale safely.', '2', '高频|SaaS'],
],
jsonExample: {
units: [
{
legacyId: 'tj-english-core-001',
name: '核心词汇 Unit 1',
words: [
{
legacyId: 'word-scale',
word: 'scale',
phonetic: '/skeil/',
meaning: 'n. 规模;等级',
example: 'The platform must scale safely.',
difficulty: 2,
},
],
},
],
},
},
handbook: {
importType: 'handbook',
title: '知识手册导入模板',
description: '用于导入知识手册科目、章节、小节和知识点。',
fields: handbookFields,
csvRows: [
['subjectName', 'chapterName', 'sectionName', 'title', 'summary', 'content', 'tags'],
['专升本英语知识手册', '第一章 语法基础', '名词性从句', 'that 引导的主语从句', '主语从句核心用法。', '主语从句可放在句首,也可用 it 作形式主语。', '语法'],
],
jsonExample: {
subjects: [
{
name: '专升本英语知识手册',
chapters: [
{
name: '第一章 语法基础',
sections: [
{
name: '名词性从句',
entries: [
{
title: 'that 引导的主语从句',
summary: '主语从句核心用法。',
content: '主语从句可放在句首,也可用 it 作形式主语。',
tags: ['语法'],
},
],
},
],
},
],
},
],
},
},
scoreline: {
importType: 'scoreline',
title: '分数线导入模板',
description: '用于导入动态字段、院校、专业和年份分数线记录。',
fields: scorelineFields,
csvRows: [
['kind', 'legacyId', 'schoolName', 'majorName', 'year', 'fieldKey', 'fieldName', 'minScore'],
['field', 'score-field-min', '', '', '', 'minScore', '最低分', ''],
['school', 'score-school-001', '天津职业技术师范大学', '', '', '', '', ''],
['major', 'score-major-001', '天津职业技术师范大学', '软件工程', '', '', '', ''],
['record', 'score-record-2026', '天津职业技术师范大学', '软件工程', '2026', '', '', '188'],
],
jsonExample: {
fields: [{ legacyId: 'score-field-min', fieldKey: 'minScore', fieldName: '最低分', fieldType: 'number', isTrend: true }],
schools: [{ legacyId: 'score-school-001', name: '天津职业技术师范大学' }],
majors: [{ legacyId: 'score-major-001', schoolLegacyId: 'score-school-001', name: '软件工程' }],
records: [{ legacyId: 'score-record-2026', schoolLegacyId: 'score-school-001', majorLegacyId: 'score-major-001', year: 2026, fieldValues: { minScore: 188 } }],
},
},
videos: {
importType: 'videos',
title: '视频解析导入模板',
description: '用于导入视频解析元数据并绑定到题目。',
fields: videoFields,
csvRows: [
['legacyId', 'title', 'videoUrl', 'accessMode', 'legacyQuestionId', 'videoType'],
['video-question-001', '多租户隔离题解析', 'https://cdn.example.test/video.mp4', 'video_quota', 'tj-english-2026-001', 'specific'],
],
jsonExample: {
videos: [
{
legacyId: 'video-question-001',
title: '多租户隔离题解析',
videoUrl: 'https://cdn.example.test/video.mp4',
accessMode: 'video_quota',
bindings: [{ legacyQuestionId: 'tj-english-2026-001', videoType: 'specific' }],
},
],
},
},
};
function importTypeFromQuery(value: string): ImportType {
if (!IMPORT_TYPES.has(value as ImportType)) {
throw new HttpError(400, 'Unsupported importType', 'INVALID_IMPORT_TYPE');
}
return value as ImportType;
}
function formatFromQuery(value: string): TemplateFormat {
const format = value || 'json';
if (!FORMATS.has(format as TemplateFormat)) {
throw new HttpError(400, 'Unsupported template format', 'INVALID_TEMPLATE_FORMAT');
}
return format as TemplateFormat;
}
function csvEscape(value: unknown) {
const text = String(value ?? '');
if (!/[",\r\n]/.test(text)) return text;
return `"${text.replaceAll('"', '""')}"`;
}
function toCsv(rows: string[][]) {
return `${rows.map(row => row.map(csvEscape).join(',')).join('\n')}\n`;
}
function contentFor(spec: ImportTemplateSpec, format: TemplateFormat) {
if (format === 'csv') return toCsv(spec.csvRows);
return `${JSON.stringify(spec.jsonExample, null, 2)}\n`;
}
export async function importFieldMappingRoute(ctx: RequestContext) {
await requireTenantContentEditor(ctx);
const importType = importTypeFromQuery(stringParam(ctx, 'importType'));
const spec = SPECS[importType];
return {
item: {
importType,
title: spec.title,
description: spec.description,
fields: spec.fields,
requiredFields: spec.fields.filter(field => field.required).map(field => field.field),
},
};
}
export async function importTemplateRoute(ctx: RequestContext) {
await requireTenantContentEditor(ctx);
const importType = importTypeFromQuery(stringParam(ctx, 'importType'));
const format = formatFromQuery(stringParam(ctx, 'format'));
const spec = SPECS[importType];
const content = contentFor(spec, format);
const extension = format === 'csv' ? 'csv' : 'json';
const mimeType = format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json; charset=utf-8';
return {
item: {
importType,
format,
fileName: `${importType}-import-template.${extension}`,
mimeType,
contentBase64: Buffer.from(content, 'utf8').toString('base64'),
contentPreview: content,
fields: spec.fields,
},
};
}

View File

@@ -21,6 +21,14 @@ import {
previewVideosImportRoute, previewVideosImportRoute,
previewVocabularyImportRoute, previewVocabularyImportRoute,
} from './imports.js'; } from './imports.js';
import {
importPostCheckRoute,
importPostCheckStatusRoute,
} from './import-postcheck.js';
import {
importFieldMappingRoute,
importTemplateRoute,
} from './import-templates.js';
import { import {
contentEntriesAdminRoute, contentEntriesAdminRoute,
contentNodesAdminRoute, contentNodesAdminRoute,
@@ -94,6 +102,10 @@ export const tenantContentRoutes: RouteDefinition[] = [
['POST', '/api/tenant-content/imports/videos', importVideosRoute], ['POST', '/api/tenant-content/imports/videos', importVideosRoute],
['GET', '/api/tenant-content/imports', importJobsRoute], ['GET', '/api/tenant-content/imports', importJobsRoute],
['GET', '/api/tenant-content/imports/issues', importIssuesRoute], ['GET', '/api/tenant-content/imports/issues', importIssuesRoute],
['GET', '/api/tenant-content/imports/field-mapping', importFieldMappingRoute],
['GET', '/api/tenant-content/imports/templates', importTemplateRoute],
['POST', '/api/tenant-content/imports/post-check', importPostCheckRoute],
['GET', '/api/tenant-content/imports/post-check', importPostCheckStatusRoute],
['GET', '/api/tenant-content/videos', videosAdminRoute], ['GET', '/api/tenant-content/videos', videosAdminRoute],
['PUT', '/api/tenant-content/videos', upsertVideoRoute], ['PUT', '/api/tenant-content/videos', upsertVideoRoute],
['POST', '/api/tenant-content/question-videos', bindQuestionVideoRoute], ['POST', '/api/tenant-content/question-videos', bindQuestionVideoRoute],

View File

@@ -163,8 +163,8 @@
| 知识手册 JSON preview/import | 可联调 | 支持书籍/章节/小节/知识点归一化 | | 知识手册 JSON preview/import | 可联调 | 支持书籍/章节/小节/知识点归一化 |
| 分数线 JSON preview/import | 可联调 | 支持 `fields/schools/majors/records` 分桶或 `items` 列表,后端校验租户地区和院校/专业引用 | | 分数线 JSON preview/import | 可联调 | 支持 `fields/schools/majors/records` 分桶或 `items` 列表,后端校验租户地区和院校/专业引用 |
| 视频 JSON preview/import | 可联调 | 支持 `videos/items`,后端校验题目、科目、资源引用,导入后写入 `question_videos` | | 视频 JSON preview/import | 可联调 | 支持 `videos/items`,后端校验题目、科目、资源引用,导入后写入 `question_videos` |
| Excel/CSV 导入 | 可联调 | 题目、单词、知识手册、分数线、视频已支持 CSV 和 `.xlsx` 解析,解析后复用 `content_import_jobs/items/issues` 管线并保留 `parser_metadata` | | Excel/CSV 导入 | 可联调 | 题目、单词、知识手册、分数线、视频已支持 CSV 和 `.xlsx` 解析,解析后复用 `content_import_jobs/items/issues` 管线并保留 `parser_metadata`;模板下载、字段映射 API 和导入后复检已接入 |
| 大批量异步导入 | 可联调 | `executionMode=async` 会将 preview job 置为 `pending``apps/worker --job imports` 抢占 queued job复用 API 导入 executor支持重试、清锁和审计;导入后复检待补 | | 大批量异步导入 | 可联调 | `executionMode=async` 会将 preview job 置为 `pending``apps/worker --job imports` 抢占 queued job复用 API 导入 executor支持重试、清锁和审计 |
| 公共题库版本同步 | 待补齐 | 当前采纳为快照复制;后续需 worker 做增量同步、冲突处理、版本升级通知和租户自改保护 | | 公共题库版本同步 | 待补齐 | 当前采纳为快照复制;后续需 worker 做增量同步、冲突处理、版本升级通知和租户自改保护 |
## 当前验证 ## 当前验证

View File

@@ -14,7 +14,7 @@
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置和入队能力。 - 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置和入队能力。
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。 - 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信生产账号、微信网页登录/QQ 登录、真实数据 dry-run 迁移导入后复检模板字段映射仍需要继续补齐或联调 因此,后端现在已经具备进入 Taro 前端第一阶段联调的基础。需要注意的是,它还不是完整生产交付状态,真实云端鉴权、对象存储生产安全、支付/短信生产账号、微信网页登录/QQ 登录、真实数据 dry-run 迁移仍需要继续补齐或联调;导入后复检模板下载和字段映射 API 已可联调,前端操作台待接
## 后端模块进度 ## 后端模块进度
@@ -32,7 +32,7 @@
| 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 | | 会员与订单 | 可联调 | 下单、订单详情/状态轮询、优惠券领取/抵扣、零元订单自动开通、手工确认权限保护、激活码预检查/兑换、微信支付、支付宝、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、支付/退款补偿 worker、权益发放 | 完整资金流水对账、异常订单运营台 |
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、OAuth 配置表 | 微信网页登录、QQ 登录、手机号换绑、真实生产账号联调 | | 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、OAuth 配置表 | 微信网页登录、QQ 登录、手机号换绑、真实生产账号联调 |
| 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker | | 销售/代理/CRM | 基础完成 | 邀请码、首绑保护、团队关系、销售统计、CRM 入队 | 小程序码真实生成、分佣结算、钉钉/飞书/企微 worker |
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job、审计、幂等、`executionMode=async`imports worker | 导入后复检、模板下载/字段映射 | | 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job、审计、幂等、`executionMode=async`imports worker导入后复检、模板下载和字段映射 API | 字段映射 UI、真实数据 dry-run 和导入性能压测 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | | 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 | | AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
| Taro 前端 | 未开始 | 旧 Web 已有新 API 适配雏形 | `apps/taro`、跨端 API client、H5/小程序页面和端到端测试 | | Taro 前端 | 未开始 | 旧 Web 已有新 API 适配雏形 | `apps/taro`、跨端 API client、H5/小程序页面和端到端测试 |
@@ -87,7 +87,7 @@
- XPay 或其它实际支付网关 adapter。 - XPay 或其它实际支付网关 adapter。
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。 - 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。
- 公共题库/地区题库版本同步,租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。 - 公共题库/地区题库版本同步,租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
- 导入后复检和导入模板/字段映射 UI大批量导入已支持 `executionMode=async`,前端按 job 状态轮询。 - 导入模板字段映射和复检 API 已可用;前端继续补模板下载按钮、字段映射 UI、job 状态轮询和复检结果面板
- 视频深度防盗链、动态水印和播放统计。 - 视频深度防盗链、动态水印和播放统计。
- 数据看板 API收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。 - 数据看板 API收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。

View File

@@ -253,11 +253,11 @@ GET /api/tenant-admin/audit-logs
- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider上传确认和 assets worker 已支持对象元数据校验/复检。PDF 预览渲染、防盗链、水印和安全扫描仍需继续补。 - 内容资源当前完成台账、租户后台维护、学生端 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` 管理,练习 session 会保存当次题目 ID 快照。
- 练习访问控制由 `content_entries/content_nodes/question_collections/practice_blueprints``accessRules` 合并决定;普通用户消耗 `practice_daily_usage`,事件写入 `practice_access_events`SVIP/staff 不消耗免费额度。 - 练习访问控制由 `content_entries/content_nodes/question_collections/practice_blueprints``accessRules` 合并决定;普通用户消耗 `practice_daily_usage`,事件写入 `practice_access_events`SVIP/staff 不消耗免费额度。
- 批量导入当前支持题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、逐行 issue、job/item 台账、同步执行或 `executionMode=async` 异步执行、幂等跳过,并可落到新内容入口、分类节点、分数线表或题目视频绑定。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。导入后复检、模板下载和字段映射 UI 后续补齐 - 批量导入当前支持题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、逐行 issue、job/item 台账、同步执行或 `executionMode=async` 异步执行、幂等跳过,并可落到新内容入口、分类节点、分数线表或题目视频绑定。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。导入后复检、模板下载和字段映射 API 已补齐,前端 UI 待接
## 下一步 ## 下一步
1. 完善内容导入和文件上传:导入后复检、模板下载/字段映射 UIPDF 预览渲染、防盗链、杀毒扫描和视频水印。 1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、PDF 预览渲染、防盗链、杀毒扫描和视频水印。
2. 接入真实短信 provider阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。 2. 接入真实短信 provider阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
3. 接入真实 OAuth provider微信网页、微信小程序、QQ并处理旧 PocketBase 身份映射。 3. 接入真实 OAuth provider微信网页、微信小程序、QQ并处理旧 PocketBase 身份映射。
4. 补完整资金流水对账、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认和退款通知主链路已完成。 4. 补完整资金流水对账、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认和退款通知主链路已完成。

View File

@@ -18,7 +18,7 @@
| 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略、地区/全国套餐权限、平台侧主题模板库、平台审计 | | 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略、地区/全国套餐权限、平台侧主题模板库、平台审计 |
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 | | 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
| 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student权限矩阵成员启停角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 | | 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student权限矩阵成员启停角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 |
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、视频绑定、分数线、单词、知识手册后台 API、公共题库授权和采纳快照 | 导入后复检、模板/字段映射、公共题库版本同步、可视化拖拽排序前端 | | 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权和采纳快照 | 字段映射 UI、公共题库版本同步、可视化拖拽排序前端 |
| 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹、模考交卷评分报告、错题复习计划、排行榜 | 专项练习策略、题型统计深度分析、排行榜防刷/预聚合 | | 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹、模考交卷评分报告、错题复习计划、排行榜 | 专项练习策略、题型统计深度分析、排行榜防刷/预聚合 |
| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、每日复习计划、旧模板/新模板 JSON/CSV/Excel 预览导入、内容导航绑定、排行榜 | 更细复习参数 | | 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、每日复习计划、旧模板/新模板 JSON/CSV/Excel 预览导入、内容导航绑定、排行榜 | 更细复习参数 |
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 | | 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 |
@@ -36,7 +36,7 @@
## 接下来优先级 ## 接下来优先级
1. 完善内容导入和对象存储:导入后复检、模板下载/字段映射 UI、CDN 防盗链、杀毒扫描和视频水印。 1. 完善内容导入和对象存储:字段映射 UI、真实数据 dry-run、CDN 防盗链、杀毒扫描和视频水印。
2. 公共题库/地区题库授权:已完成披露和采纳快照;继续补版本同步、租户自改冲突处理和按 SaaS 套餐限制地区。 2. 公共题库/地区题库授权:已完成披露和采纳快照;继续补版本同步、租户自改冲突处理和按 SaaS 套餐限制地区。
3. 学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 3. 学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
4. 视频会员控制:深度防盗链、水印和播放统计。 4. 视频会员控制:深度防盗链、水印和播放统计。

View File

@@ -41,6 +41,10 @@ POST /api/tenant-content/imports/videos
GET /api/tenant-content/imports GET /api/tenant-content/imports
GET /api/tenant-content/imports/issues GET /api/tenant-content/imports/issues
GET /api/tenant-content/imports/field-mapping
GET /api/tenant-content/imports/templates
POST /api/tenant-content/imports/post-check
GET /api/tenant-content/imports/post-check
``` ```
所有导入都会写入: 所有导入都会写入:
@@ -93,6 +97,65 @@ npm --workspace @tiku-saas/worker run imports:once
前端提交异步导入后不要重复同步执行同一 job只需要轮询 `GET /api/tenant-content/imports` 并用 `GET /api/tenant-content/imports/issues` 展示问题行。worker 会按 `attempt_count/max_attempts` 记录重试,失败时写入 `errorMessage` 和审计日志。 前端提交异步导入后不要重复同步执行同一 job只需要轮询 `GET /api/tenant-content/imports` 并用 `GET /api/tenant-content/imports/issues` 展示问题行。worker 会按 `attempt_count/max_attempts` 记录重试,失败时写入 `errorMessage` 和审计日志。
## 模板、字段映射和导入后复检
租户后台前端不要把导入字段写死在页面里。导入页初始化时先读取字段映射,下载模板时调用模板接口:
```text
GET /api/tenant-content/imports/field-mapping?importType=questions
GET /api/tenant-content/imports/templates?importType=questions&format=csv
```
`importType` 支持:
```text
questions | vocabulary | handbook | scoreline | videos
```
`templates` 返回:
```json
{
"item": {
"importType": "questions",
"format": "csv",
"fileName": "questions-import-template.csv",
"mimeType": "text/csv; charset=utf-8",
"contentBase64": "...",
"fields": []
}
}
```
前端可用 `contentBase64` 生成下载文件,或用 `contentPreview` 做在线预览。`fields` 包含规范字段、中文别名、是否必填和示例,适合做字段映射 UI。
导入完成后,租户后台应主动触发复检:
```json
POST /api/tenant-content/imports/post-check
{
"jobId": "uuid"
}
```
复检会确认:
- job 行数、有效行、已处理行是否一致。
- 处理成功的 item 是否都有 `target_id`
- 题目是否存在当前版本,且已绑定目标集合。
- 单词单元是否存在且有单词。
- 知识手册是否存在章节/条目。
- 分数线字段、院校、专业、记录是否都落到对应表。
- 视频是否存在题目绑定。
复检结果会写入 `content_import_jobs.summary.importPostCheck`,也可以通过:
```text
GET /api/tenant-content/imports/post-check?jobId=<jobId>
```
读取。异步导入未完成时触发复检会返回 `IMPORT_JOB_NOT_COMPLETED`
推荐 CSV/Excel 表头: 推荐 CSV/Excel 表头:
| 类型 | 常用表头 | | 类型 | 常用表头 |
@@ -315,5 +378,6 @@ npm --workspace @tiku-saas/worker run imports:once
## 下一步 ## 下一步
- 增加导入后校验/复检,确认导入行数、目标表记录、题目集合绑定和视频绑定一致 - 用真实 PocketBase 全量导出数据做多轮 dry-run并把复检报告作为上线验收材料
- 增加导入模板下载接口和后台可视化字段映射器 - 前端补可视化字段映射、模板下载按钮、导入 job 轮询、逐行 issue 展示和复检结果面板
- 后续按大租户数据量补导入任务分页预览、抽样校验和导入性能压测。

View File

@@ -45,7 +45,7 @@
- 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。 - 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。
- 真实短信、微信登录、QQ 登录、微信支付、支付宝支付 provider 还未正式接完。 - 真实短信、微信登录、QQ 登录、微信支付、支付宝支付 provider 还未正式接完。
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览和资源复检 worker但 CDN 防盗链、视频水印和杀毒扫描还要补。 - 对象存储已完成签名 provider、上传后校验、PDF/图片预览和资源复检 worker但 CDN 防盗链、视频水印和杀毒扫描还要补。
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker前端还要补上传预览、排队轮询、问题行展示、模板下载字段映射 UI。 - 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API 和导入后复检已可用,前端还要补上传预览、排队轮询、问题行展示、模板下载按钮、字段映射 UI 和复检结果面板
- 数据看板、分佣结算和勋章手动发放基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题模板市场等仍是后续商用增强项。 - 数据看板、分佣结算和勋章手动发放基础 API 已可联调;勋章自动发放、分佣真实打款/导出/凭证、AI 择校、主题模板市场等仍是后续商用增强项。
## 前后端协作建议 ## 前后端协作建议

View File

@@ -24,7 +24,7 @@
| 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 | | 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| 多租户隔离 | 已建 `tenants``tenant_domains``tenant_branding``tenant_settings`、RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环未完成 | | 多租户隔离 | 已建 `tenants``tenant_domains``tenant_branding``tenant_settings`、RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环未完成 |
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、公共题库授权/采纳表 | 已支持核心映射JSON/CSV/Excel 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、异步导入 worker、平台公共题库授权、租户采纳快照已实现 | 核心 API 集成测试含导航、组卷、导入、公共题库授权和采纳后组卷断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳快照可联调;公共题库全量/增量版本同步、导入后复检仍需补齐 | | 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、公共题库授权/采纳表 | 已支持核心映射JSON/CSV/Excel 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、异步导入 worker、平台公共题库授权、租户采纳快照已实现 | 核心 API 集成测试含导航、组卷、导入、公共题库授权和采纳后组卷断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳快照、导入后复检、模板下载和字段映射 API 可联调;公共题库全量/增量版本同步仍需补齐 |
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 | | 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 | | 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
| 用户订阅/题库会员/SVIP | 已建 `orders``payments``entitlements``svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 | | 用户订阅/题库会员/SVIP | 已建 `orders``payments``entitlements``svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 |
@@ -36,7 +36,7 @@
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、会员状态、最近练习、统计聚合、签到积分、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试 | 学生端基础个人中心已实现,账号绑定/换绑、学习报告可视化和更细任务系统待补 | | 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、会员状态、最近练习、统计聚合、签到积分、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试 | 学生端基础个人中心已实现,账号绑定/换绑、学习报告可视化和更细任务系统待补 |
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、勋章维护和手动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,勋章自动发放、复杂活动规则、营销自动化、核销报表待补 | | 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、勋章维护和手动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,勋章自动发放、复杂活动规则、营销自动化、核销报表待补 |
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、导入后复检和字段映射操作台待补 | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、字段映射操作台和导入复检结果面板待补 |
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 | | 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 |
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/微信网页/QQ 登录生产联调待补 | | 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/微信网页/QQ 登录生产联调待补 |
| 数据导入 | 已建立 importer、risk report、validate | 已覆盖多类旧集合 | 命令行导入/校验 | `pb:import:validate` | 基础工具可用,需用真实完整数据做多轮 dry-run | | 数据导入 | 已建立 importer、risk report、validate | 已覆盖多类旧集合 | 命令行导入/校验 | `pb:import:validate` | 基础工具可用,需用真实完整数据做多轮 dry-run |
@@ -268,7 +268,7 @@ platform-admin:
1. 正式鉴权API 已支持 Supabase Auth JWT生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id``x-platform-admin-key` 兼容入口。 1. 正式鉴权API 已支持 Supabase Auth JWT生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id``x-platform-admin-key` 兼容入口。
2. 国内能力接入:短信、微信小程序登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备微信网页登录、QQ 登录、真实生产账号联调、对账和支付补偿仍需实现。 2. 国内能力接入:短信、微信小程序登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备微信网页登录、QQ 登录、真实生产账号联调、对账和支付补偿仍需实现。
3. 核心缺口 API学生端个人中心、分数线、题目视频详情、背单词进度/收藏、签到积分、题目反馈和勋章已补基础 API下一步重点是账号绑定、学习报告可视化、后台统计和真实业务验收。 3. 核心缺口 API学生端个人中心、分数线、题目视频详情、背单词进度/收藏、签到积分、题目反馈和勋章已补基础 API下一步重点是账号绑定、学习报告可视化、后台统计和真实业务验收。
4. 后台能力:题库录入、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API导入后复检、模板下载/字段映射和前端操作台待补。 4. 后台能力:题库录入、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步批量导入、导入后复检、模板下载/字段映射 API、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API前端操作台待补。
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。 5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、退款对账和前端端到端测试。
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。 6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。
7. 运维交付:生产环境变量、备份恢复、日志监控、异常告警、数据库迁移流程、灰度发布、回滚预案。 7. 运维交付:生产环境变量、备份恢复、日志监控、异常告警、数据库迁移流程、灰度发布、回滚预案。
@@ -277,7 +277,7 @@ platform-admin:
为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续: 为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续:
1. 完善内容导入和文件上传:导入后复检、模板下载/字段映射 UI、CDN 防盗链、杀毒扫描。 1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run、CDN 防盗链、杀毒扫描。
2. 补公共题库版本同步 worker、租户套餐地区/科目/题库范围限制、主题模板系统。 2. 补公共题库版本同步 worker、租户套餐地区/科目/题库范围限制、主题模板系统。
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
4. 补视频商用控制:深度防盗链、动态水印和播放统计。 4. 补视频商用控制:深度防盗链、动态水印和播放统计。

View File

@@ -58,7 +58,7 @@
| 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券幂等、下单抵扣、全额优惠自动开通权益已有;复杂活动规则和核销报表待补 | | 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券幂等、下单抵扣、全额优惠自动开通权益已有;复杂活动规则和核销报表待补 |
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 | | 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 | | 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 |
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图已有;导入后复检、模板下载和字段映射 UI 待补 | | 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图导入后复检、模板下载和字段映射 API 已有;字段映射 UI 和复检结果操作台待补 |
| 题库导出 PDF/Word/JSON | 未覆盖 | 旧前端有导出组件;新后端需决定服务端导出、导出水印和权限审计 | | 题库导出 PDF/Word/JSON | 未覆盖 | 旧前端有导出组件;新后端需决定服务端导出、导出水印和权限审计 |
| 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 | | 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 |
| 背单词维护 | 已覆盖 | 单元/单词 CRUD 和导入已有 | | 背单词维护 | 已覆盖 | 单元/单词 CRUD 和导入已有 |
@@ -98,7 +98,7 @@
1. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。 1. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
2. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。 2. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。
3. 题库导出PDF/Word/JSON 导出、水印、导出审计和权限控制。 3. 题库导出PDF/Word/JSON 导出、水印、导出审计和权限控制。
4. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker仍缺导入后复检模板下载/字段映射 UI 4. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker导入后复检模板下载和字段映射 API 已补,仍缺前端字段映射 UI 和真实数据 dry-run
5. 公共题库商业化:平台公共/地区题库授权和租户快照采纳已完成基础闭环;还需版本同步、租户自改冲突处理和运营后台 UI。 5. 公共题库商业化:平台公共/地区题库授权和租户快照采纳已完成基础闭环;还需版本同步、租户自改冲突处理和运营后台 UI。
6. CRM/销售结算CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成仍缺轮询/定向分配、打款导出、凭证和销售结算看板。 6. CRM/销售结算CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
@@ -117,7 +117,7 @@
1. 微信/支付宝支付和 webhook 幂等。 1. 微信/支付宝支付和 webhook 幂等。
2. 对象存储 PDF 预览、视频深度防盗链、动态水印。 2. 对象存储 PDF 预览、视频深度防盗链、动态水印。
3. 导入后复检、模板下载和字段映射 UI。 3. 字段映射 UI、真实数据 dry-run 和导入复检结果操作台
4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。 4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。
5. 公共题库版本同步、租户采纳后的更新策略和同步 worker。 5. 公共题库版本同步、租户采纳后的更新策略和同步 worker。

View File

@@ -77,7 +77,7 @@
3. 导入体系扩展 3. 导入体系扩展
- 已完成题目、单词、知识手册、分数线、视频的 CSV/Excel 到规范 JSON 解析适配。 - 已完成题目、单词、知识手册、分数线、视频的 CSV/Excel 到规范 JSON 解析适配。
- 继续补大批量导入异步 worker、重试、导入后校验。 - 继续补大批量导入异步 worker、重试、导入后校验。
- 继续补模板下载、字段映射 UI 和导入前抽样校验 - 模板下载、字段映射 API 和导入后复检已补;继续补字段映射 UI、真实数据 dry-run、导入前抽样校验和复检结果操作台
4. 公共题库和租户授权 4. 公共题库和租户授权
- 已完成平台公共题库/地区题库的基础授权、租户采纳和题目快照复制。 - 已完成平台公共题库/地区题库的基础授权、租户采纳和题目快照复制。

View File

@@ -730,15 +730,22 @@ POST /api/tenant-content/imports/preview/videos
POST /api/tenant-content/imports/videos POST /api/tenant-content/imports/videos
GET /api/tenant-content/imports GET /api/tenant-content/imports
GET /api/tenant-content/imports/issues GET /api/tenant-content/imports/issues
GET /api/tenant-content/imports/field-mapping
GET /api/tenant-content/imports/templates
POST /api/tenant-content/imports/post-check
GET /api/tenant-content/imports/post-check
``` ```
前端流程: 前端流程:
1. 上传或粘贴 JSON/CSV/Excel先调用对应 preview 1. 页面初始化调用 `field-mapping`,渲染字段说明、别名、必填项和示例
2. 展示 `job.totalCount/validCount/errorCount/warningCount` 2. 下载模板调用 `templates?importType=...&format=csv|json`,用 `contentBase64` 生成文件
3. 展示 `job.sourceFormat``job.parserMetadata`、逐行 `issues`,错误行必须让运营修正;如果后端允许 `allowPartial`,也要二次确认 3. 上传或粘贴 JSON/CSV/Excel先调用对应 preview
4. 小批量确认后直接调用 import大批量确认时传 `executionMode=async` 排队,前端轮询 job 状态 4. 展示 `job.totalCount/validCount/errorCount/warningCount`
5. 导入完成后刷新内容列表、分数线列表或题目视频列表 5. 展示 `job.sourceFormat``job.parserMetadata`、逐行 `issues`,错误行必须让运营修正;如果后端允许 `allowPartial`,也要二次确认
6. 小批量确认后直接调用 import大批量确认时传 `executionMode=async` 排队,前端轮询 job 状态。
7. 导入进入 `completed/completed_with_errors` 后调用 `POST /api/tenant-content/imports/post-check`
8. 展示 `summary.importPostCheck``GET /api/tenant-content/imports/post-check?jobId=...` 返回的复检结果,再刷新内容列表、分数线列表或题目视频列表。
CSV 请求示例: CSV 请求示例:
@@ -786,6 +793,16 @@ completed_with_errors刷新成功内容并提示查看 issues。
failed/rejected展示 errorMessage 和 issues允许运营修正后重新 preview。 failed/rejected展示 errorMessage 和 issues允许运营修正后重新 preview。
``` ```
导入复检状态:
```text
passed可以展示为导入验收通过。
warning导入已落库但存在可运营确认的风险例如 allowPartial 导入。
failed导入结果和目标表不一致必须提示管理员排查不要静默刷新页面。
```
前端不要自行判断导入成功率,也不要只看 `completed` 就认为可上线;以复检结果和目标内容刷新结果共同作为运营提示。
前端文件限制应与后端一致:单文件最大 8MB最多 5000 行、160 列。后端不会保存原始 `fileBase64`,但前端仍不要把含隐私的导入文件写入长期缓存。 前端文件限制应与后端一致:单文件最大 8MB最多 5000 行、160 列。后端不会保存原始 `fileBase64`,但前端仍不要把含隐私的导入文件写入长期缓存。
分数线导入前端注意: 分数线导入前端注意:

View File

@@ -2659,6 +2659,33 @@ async function testTenantContentAssetsAndImports() {
}); });
assert.equal(deniedImport.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not preview content import'); assert.equal(deniedImport.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not preview content import');
const deniedTemplate = await request('/api/tenant-content/imports/templates', {
query: { importType: 'questions', format: 'csv' },
expectStatus: 403,
});
assert.equal(deniedTemplate.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not download import templates');
const questionFieldMapping = await request('/api/tenant-content/imports/field-mapping', {
userId: TENANT_ADMIN_USER_ID,
query: { importType: 'questions' },
});
assert.ok(
questionFieldMapping.item?.fields?.some(field => field.field === 'content' && field.required === true),
'question field mapping should describe required content field',
);
assert.ok(
questionFieldMapping.item?.fields?.some(field => field.aliases?.includes('题干')),
'question field mapping should include Chinese aliases for old import operators',
);
const scorelineTemplate = await request('/api/tenant-content/imports/templates', {
userId: TENANT_ADMIN_USER_ID,
query: { importType: 'scoreline', format: 'csv' },
});
assert.equal(scorelineTemplate.item?.fileName, 'scoreline-import-template.csv', 'template endpoint should return deterministic filename');
const scorelineTemplateText = Buffer.from(scorelineTemplate.item?.contentBase64 || '', 'base64').toString('utf8');
assert.ok(scorelineTemplateText.includes('kind,legacyId'), 'CSV template should include scoreline headers');
const oversizedNormalJson = await request('/api/auth/sms/send', { const oversizedNormalJson = await request('/api/auth/sms/send', {
method: 'POST', method: 'POST',
body: { body: {
@@ -2781,11 +2808,29 @@ async function testTenantContentAssetsAndImports() {
'valid import should process all valid questions idempotently', 'valid import should process all valid questions idempotently',
); );
const questionPostCheck = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { jobId: validPreview.job.id },
});
assert.equal(questionPostCheck.item?.status, 'passed', 'question import post-check should pass after completed import');
assert.equal(questionPostCheck.item?.counts?.processedItemCount, 2, 'question post-check should count processed rows');
assert.equal(questionPostCheck.item?.counts?.collectionBindingCount, 2, 'question post-check should verify collection bindings');
const questionPostCheckStatus = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
query: { jobId: validPreview.job.id },
});
assert.equal(questionPostCheckStatus.item?.importPostCheck?.status, 'passed', 'post-check result should be readable from job summary');
const jobs = await request('/api/tenant-content/imports', { const jobs = await request('/api/tenant-content/imports', {
userId: TENANT_ADMIN_USER_ID, userId: TENANT_ADMIN_USER_ID,
query: { importType: 'questions', limit: 10 }, query: { importType: 'questions', limit: 10 },
}); });
assert.ok(jobs.items?.some(item => item.id === validPreview.job.id && item.status === 'completed'), 'import job list should include completed job'); assert.ok(
jobs.items?.some(item => item.id === validPreview.job.id && item.status === 'completed' && item.summary?.importPostCheck?.status === 'passed'),
'import job list should include completed job with post-check summary',
);
const importedQuestions = await request('/api/catalog/questions', { const importedQuestions = await request('/api/catalog/questions', {
query: { collectionId: ids.questionCollection, limit: 100 }, query: { collectionId: ids.questionCollection, limit: 100 },
@@ -2891,6 +2936,14 @@ async function testTenantContentAssetsAndImports() {
}); });
assert.equal(queuedSyncExecution.code, 'IMPORT_JOB_QUEUED', 'queued import job should not be executed synchronously'); assert.equal(queuedSyncExecution.code, 'IMPORT_JOB_QUEUED', 'queued import job should not be executed synchronously');
const queuedPostCheck = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { jobId: asyncQuestionPreview.job.id },
expectStatus: 409,
});
assert.equal(queuedPostCheck.code, 'IMPORT_JOB_NOT_COMPLETED', 'queued import job should not allow post-check before worker completion');
const vocabEntry = await request('/api/tenant-content/content-entries', { const vocabEntry = await request('/api/tenant-content/content-entries', {
userId: TENANT_ADMIN_USER_ID, userId: TENANT_ADMIN_USER_ID,
method: 'PUT', method: 'PUT',
@@ -2997,6 +3050,14 @@ async function testTenantContentAssetsAndImports() {
(vocabularyImport.item?.insertedCount || 0) + (vocabularyImport.item?.updatedCount || 0) + (vocabularyImport.item?.skippedCount || 0) >= 2, (vocabularyImport.item?.insertedCount || 0) + (vocabularyImport.item?.updatedCount || 0) + (vocabularyImport.item?.skippedCount || 0) >= 2,
'vocabulary import should process unit and word idempotently', 'vocabulary import should process unit and word idempotently',
); );
const vocabularyPostCheck = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { jobId: vocabularyPreview.job.id },
});
assert.equal(vocabularyPostCheck.item?.status, 'passed', 'vocabulary import post-check should pass');
assert.equal(vocabularyPostCheck.item?.counts?.processedItemCount, 1, 'vocabulary post-check should count unit import items');
assert.equal(vocabularyPostCheck.item?.counts?.vocabularyWordCount >= 1, true, 'vocabulary post-check should verify imported words');
const vocabularyUnits = await request('/api/catalog/vocabulary-units', { const vocabularyUnits = await request('/api/catalog/vocabulary-units', {
query: { regionId: ids.region }, query: { regionId: ids.region },
@@ -3124,6 +3185,13 @@ async function testTenantContentAssetsAndImports() {
(handbookImport.item?.insertedCount || 0) + (handbookImport.item?.updatedCount || 0) + (handbookImport.item?.skippedCount || 0) >= 3, (handbookImport.item?.insertedCount || 0) + (handbookImport.item?.updatedCount || 0) + (handbookImport.item?.skippedCount || 0) >= 3,
'handbook import should process subject, chapter, and entry idempotently', 'handbook import should process subject, chapter, and entry idempotently',
); );
const handbookPostCheck = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { jobId: handbookPreview.job.id },
});
assert.equal(handbookPostCheck.item?.status, 'passed', 'handbook import post-check should pass');
assert.equal(handbookPostCheck.item?.counts?.handbookEntryCount >= 1, true, 'handbook post-check should verify entries');
const handbookSubjects = await request('/api/catalog/handbook-subjects', { const handbookSubjects = await request('/api/catalog/handbook-subjects', {
query: { regionId: ids.region }, query: { regionId: ids.region },
@@ -3220,6 +3288,13 @@ async function testTenantContentAssetsAndImports() {
(scorelineImport.item?.insertedCount || 0) + (scorelineImport.item?.updatedCount || 0) + (scorelineImport.item?.skippedCount || 0) >= 4, (scorelineImport.item?.insertedCount || 0) + (scorelineImport.item?.updatedCount || 0) + (scorelineImport.item?.skippedCount || 0) >= 4,
'scoreline import should process all valid rows', 'scoreline import should process all valid rows',
); );
const scorelinePostCheck = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { jobId: scorelinePreview.job.id },
});
assert.equal(scorelinePostCheck.item?.status, 'passed', 'scoreline import post-check should pass');
assert.equal(scorelinePostCheck.item?.counts?.scorelineRecordExistingCount, 1, 'scoreline post-check should verify record target');
const importedScorelineRecords = await request('/api/scoreline/records', { const importedScorelineRecords = await request('/api/scoreline/records', {
query: { regionId: ids.region, year: 2025, pageSize: 50 }, query: { regionId: ids.region, year: 2025, pageSize: 50 },
@@ -3374,6 +3449,13 @@ async function testTenantContentAssetsAndImports() {
(videoImport.item?.insertedCount || 0) + (videoImport.item?.updatedCount || 0) + (videoImport.item?.skippedCount || 0) >= 2, (videoImport.item?.insertedCount || 0) + (videoImport.item?.updatedCount || 0) + (videoImport.item?.skippedCount || 0) >= 2,
'video import should process video and question binding', 'video import should process video and question binding',
); );
const videoPostCheck = await request('/api/tenant-content/imports/post-check', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: { jobId: videoPreview.job.id },
});
assert.equal(videoPostCheck.item?.status, 'passed', 'video import post-check should pass');
assert.equal(videoPostCheck.item?.counts?.videoBindingCount >= 1, true, 'video post-check should verify question video bindings');
const questionVideosAfterImport = await request(`/api/questions/${ids.question}/videos`); const questionVideosAfterImport = await request(`/api/questions/${ids.question}/videos`);
assert.ok( assert.ok(