forked from wangziqi/gongxue-base
feat: add question export foundation
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
|
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
|
||||||
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
||||||
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖。
|
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖。
|
||||||
|
- 题库导出基础能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json` 和打印 payload,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
|
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
|
||||||
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据并自动下架异常资源。
|
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据并自动下架异常资源。
|
||||||
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路、微信/支付宝发起退款/查询确认/退款通知、支付/退款补偿 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 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||||
|
- 题库导出目前完成服务端结构化 payload;PDF/Word 二进制生成、导出水印、发布到资料下载和导出 worker 还没完成。
|
||||||
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库自动同步 worker 和冲突操作台还没完成。
|
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库自动同步 worker 和冲突操作台还没完成。
|
||||||
- Taro 跨端前端还没开始 scaffold。
|
- Taro 跨端前端还没开始 scaffold。
|
||||||
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
||||||
@@ -176,7 +178,7 @@ apps/api/src/features/
|
|||||||
scoreline/ 分数线
|
scoreline/ 分数线
|
||||||
tenant/ 租户解析
|
tenant/ 租户解析
|
||||||
tenant-admin/ 租户后台配置、成员权限、班级学生、活动、勋章和审计
|
tenant-admin/ 租户后台配置、成员权限、班级学生、活动、勋章和审计
|
||||||
tenant-content/ 租户内容导航、题库维护、资源管理、批量导入
|
tenant-content/ 租户内容导航、题库维护、资源管理、批量导入和题库导出
|
||||||
video/ 题目视频讲解
|
video/ 题目视频讲解
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -195,6 +197,7 @@ API 身份上下文:
|
|||||||
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;生产环境应定时运行 assets worker 复检对象元数据,异常资源会被标记 failed 并退回 draft。
|
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL;生产环境应定时运行 assets worker 复检对象元数据,异常资源会被标记 failed 并退回 draft。
|
||||||
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
|
||||||
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
|
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 导入已走这套后台校验管线;大批量任务可提交 `executionMode=async`,由 imports worker 消费,前端只轮询 job 状态和展示 issues。
|
||||||
|
- 题库导出必须由后端按权限生成,不允许前端直接读取数据库拼导出文件;不开启答案/解析时,顶层题目和复合题子题都必须脱敏。
|
||||||
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款。
|
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用;生产环境还应定时运行 commerce worker 兜底供应商漏通知和处理中退款。
|
||||||
|
|
||||||
## 最近一次验证
|
## 最近一次验证
|
||||||
@@ -214,5 +217,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、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
4. 题库导出 PDF/Word worker、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||||
5. 微信网页/QQ 登录、完整资金流水对账、公共题库自动同步 worker/冲突操作台、积分活动深化,以及排行榜防刷/预聚合。
|
5. 微信网页/QQ 登录、完整资金流水对账、公共题库自动同步 worker/冲突操作台、积分活动深化,以及排行榜防刷/预聚合。
|
||||||
|
|||||||
594
apps/api/src/features/tenant-content/exports.ts
Normal file
594
apps/api/src/features/tenant-content/exports.ts
Normal file
@@ -0,0 +1,594 @@
|
|||||||
|
import type pg from 'pg';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||||
|
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||||
|
import { query, transaction } from '../../core/db.js';
|
||||||
|
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
|
||||||
|
import { boolValue, jsonObjectValue, nullableString } from './utils.js';
|
||||||
|
|
||||||
|
const EXPORT_FORMATS = ['json', 'paper_json', 'print_payload'];
|
||||||
|
const EXPORT_TYPES = ['questions', 'paper', 'daily_practice'];
|
||||||
|
const SCOPE_TYPES = ['collection', 'entry', 'content_node'];
|
||||||
|
const SUB_QUESTION_ANSWER_KEYS = new Set([
|
||||||
|
'correctOptionIndex',
|
||||||
|
'correct_option_index',
|
||||||
|
'correctOptionIndices',
|
||||||
|
'correct_option_indices',
|
||||||
|
'answerText',
|
||||||
|
'answer_text',
|
||||||
|
'answer',
|
||||||
|
'correctAnswer',
|
||||||
|
'correct_answer',
|
||||||
|
'referenceAnswer',
|
||||||
|
'reference_answer',
|
||||||
|
'solution',
|
||||||
|
]);
|
||||||
|
const SUB_QUESTION_EXPLANATION_KEYS = new Set(['explanation', 'analysis']);
|
||||||
|
|
||||||
|
interface ExportScope {
|
||||||
|
scopeType: 'collection' | 'entry' | 'content_node';
|
||||||
|
scopeId: string;
|
||||||
|
regionId: string | null;
|
||||||
|
entryId: string | null;
|
||||||
|
entryName: string | null;
|
||||||
|
contentNodeId: string | null;
|
||||||
|
contentNodeName: string | null;
|
||||||
|
collectionId: string | null;
|
||||||
|
collectionName: string | null;
|
||||||
|
collectionType: string | null;
|
||||||
|
durationMinutes: number | null;
|
||||||
|
totalScore: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportQuestionRow {
|
||||||
|
id: string;
|
||||||
|
legacyId: string | null;
|
||||||
|
entryId: string | null;
|
||||||
|
entryName: string | null;
|
||||||
|
contentNodeId: string | null;
|
||||||
|
contentNodeName: string | null;
|
||||||
|
contentNodePath: string | null;
|
||||||
|
collectionId: string | null;
|
||||||
|
collectionName: string | null;
|
||||||
|
subjectId: string | null;
|
||||||
|
subjectName: string | null;
|
||||||
|
categoryId: string | null;
|
||||||
|
categoryName: string | null;
|
||||||
|
questionBankId: string | null;
|
||||||
|
type: string;
|
||||||
|
typeLabel: string | null;
|
||||||
|
difficulty: number | null;
|
||||||
|
tags: unknown[];
|
||||||
|
mediaUrl: string | null;
|
||||||
|
hasVideoExplanation: boolean;
|
||||||
|
sectionKey: string | null;
|
||||||
|
score: number | null;
|
||||||
|
order: number;
|
||||||
|
versionId: string | null;
|
||||||
|
content: string | null;
|
||||||
|
options: unknown[];
|
||||||
|
correctOptionIndex: number | null;
|
||||||
|
correctOptionIndices: unknown[];
|
||||||
|
answerText: string | null;
|
||||||
|
explanation: string | null;
|
||||||
|
subQuestions: unknown[];
|
||||||
|
codeLang: string | null;
|
||||||
|
codeTemplate: string | null;
|
||||||
|
sourceHash: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(value: unknown, allowed: string[], fallback: string, code: string) {
|
||||||
|
const candidate = nullableString(value) || fallback;
|
||||||
|
if (!allowed.includes(candidate)) {
|
||||||
|
throw new HttpError(400, `${candidate} is not supported`, code);
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Json(value: unknown) {
|
||||||
|
return Buffer.from(JSON.stringify(value, null, 2), 'utf8').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentHash(value: unknown) {
|
||||||
|
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportLimit(value: unknown) {
|
||||||
|
const parsed = Number(value ?? 1000);
|
||||||
|
if (!Number.isFinite(parsed)) return 1000;
|
||||||
|
return Math.max(1, Math.min(Math.trunc(parsed), 5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonArray(value: unknown) {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactSubQuestions(value: unknown, includeAnswers: boolean, includeExplanations: boolean): unknown[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
|
||||||
|
return value.map(item => {
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
|
||||||
|
const output: Record<string, unknown> = {};
|
||||||
|
for (const [key, nested] of Object.entries(item as Record<string, unknown>)) {
|
||||||
|
if (!includeAnswers && SUB_QUESTION_ANSWER_KEYS.has(key)) continue;
|
||||||
|
if (!includeExplanations && SUB_QUESTION_EXPLANATION_KEYS.has(key)) continue;
|
||||||
|
if (key === 'subQuestions' || key === 'sub_questions') {
|
||||||
|
output[key] = redactSubQuestions(nested, includeAnswers, includeExplanations);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
output[key] = nested;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQuestion(row: ExportQuestionRow, includeAnswers: boolean, includeExplanations: boolean, includeVideoRefs: boolean) {
|
||||||
|
const question: Record<string, unknown> = {
|
||||||
|
id: row.id,
|
||||||
|
legacyId: row.legacyId,
|
||||||
|
type: row.type,
|
||||||
|
typeLabel: row.typeLabel,
|
||||||
|
content: row.content,
|
||||||
|
options: jsonArray(row.options),
|
||||||
|
difficulty: row.difficulty,
|
||||||
|
tags: jsonArray(row.tags),
|
||||||
|
mediaUrl: row.mediaUrl,
|
||||||
|
sectionKey: row.sectionKey,
|
||||||
|
score: row.score,
|
||||||
|
order: row.order,
|
||||||
|
subjectId: row.subjectId,
|
||||||
|
subjectName: row.subjectName,
|
||||||
|
categoryId: row.categoryId,
|
||||||
|
categoryName: row.categoryName,
|
||||||
|
entryId: row.entryId,
|
||||||
|
entryName: row.entryName,
|
||||||
|
contentNodeId: row.contentNodeId,
|
||||||
|
contentNodeName: row.contentNodeName,
|
||||||
|
collectionId: row.collectionId,
|
||||||
|
collectionName: row.collectionName,
|
||||||
|
versionId: row.versionId,
|
||||||
|
sourceHash: row.sourceHash,
|
||||||
|
};
|
||||||
|
if (includeAnswers) {
|
||||||
|
question.correctOptionIndex = row.correctOptionIndex;
|
||||||
|
question.correctOptionIndices = jsonArray(row.correctOptionIndices);
|
||||||
|
question.answerText = row.answerText;
|
||||||
|
}
|
||||||
|
if (includeExplanations) {
|
||||||
|
question.explanation = row.explanation;
|
||||||
|
}
|
||||||
|
const subQuestions = redactSubQuestions(row.subQuestions, includeAnswers, includeExplanations);
|
||||||
|
if (subQuestions.length) {
|
||||||
|
question.subQuestions = subQuestions;
|
||||||
|
}
|
||||||
|
if (row.codeLang || row.codeTemplate) {
|
||||||
|
question.codeLang = row.codeLang;
|
||||||
|
question.codeTemplate = row.codeTemplate;
|
||||||
|
}
|
||||||
|
if (includeVideoRefs) {
|
||||||
|
question.hasVideoExplanation = row.hasVideoExplanation;
|
||||||
|
}
|
||||||
|
return question;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupBySection(questions: ReturnType<typeof formatQuestion>[]) {
|
||||||
|
const groups = new Map<string, { key: string; title: string; questions: ReturnType<typeof formatQuestion>[]; totalScore: number }>();
|
||||||
|
for (const question of questions) {
|
||||||
|
const key = typeof question.sectionKey === 'string' && question.sectionKey ? question.sectionKey : String(question.type || 'default');
|
||||||
|
const title = typeof question.typeLabel === 'string' && question.typeLabel ? question.typeLabel : key;
|
||||||
|
const group = groups.get(key) || { key, title, questions: [], totalScore: 0 };
|
||||||
|
group.questions.push(question);
|
||||||
|
group.totalScore += typeof question.score === 'number' ? question.score : 0;
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
return Array.from(groups.values()).map(group => ({
|
||||||
|
...group,
|
||||||
|
questionCount: group.questions.length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExportPayload(input: {
|
||||||
|
auth: TenantContentAuth;
|
||||||
|
jobId: string;
|
||||||
|
scope: ExportScope;
|
||||||
|
exportType: string;
|
||||||
|
format: string;
|
||||||
|
includeAnswers: boolean;
|
||||||
|
includeExplanations: boolean;
|
||||||
|
includeVideoRefs: boolean;
|
||||||
|
questions: ReturnType<typeof formatQuestion>[];
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
}) {
|
||||||
|
const sections = groupBySection(input.questions);
|
||||||
|
const title = typeof input.options.title === 'string' && input.options.title.trim()
|
||||||
|
? input.options.title.trim()
|
||||||
|
: input.scope.collectionName || input.scope.contentNodeName || input.scope.entryName || '题库导出';
|
||||||
|
const payload = {
|
||||||
|
_tikuExport: '3.0',
|
||||||
|
jobId: input.jobId,
|
||||||
|
tenantId: input.auth.tenantId,
|
||||||
|
exportedAt: input.createdAt,
|
||||||
|
exportType: input.exportType,
|
||||||
|
format: input.format,
|
||||||
|
title,
|
||||||
|
scope: input.scope,
|
||||||
|
options: {
|
||||||
|
includeAnswers: input.includeAnswers,
|
||||||
|
includeExplanations: input.includeExplanations,
|
||||||
|
includeVideoRefs: input.includeVideoRefs,
|
||||||
|
...input.options,
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
questionCount: input.questions.length,
|
||||||
|
sectionCount: sections.length,
|
||||||
|
totalScore: sections.reduce((sum, section) => sum + section.totalScore, 0),
|
||||||
|
durationMinutes: input.scope.durationMinutes,
|
||||||
|
},
|
||||||
|
sections,
|
||||||
|
questions: input.questions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
filename: `${title.replace(/[\\/:*?"<>|]+/g, '_') || 'question-export'}.json`,
|
||||||
|
mimeType: 'application/json',
|
||||||
|
encoding: 'base64',
|
||||||
|
contentBase64: base64Json(payload),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
renderHints: {
|
||||||
|
pdfLayout: input.exportType === 'paper' ? 'paper' : 'practice',
|
||||||
|
pageSize: 'A4',
|
||||||
|
answerPlacement: input.includeAnswers ? 'inline_or_appendix' : 'hidden',
|
||||||
|
frontendRenderer: 'apps/taro admin export renderer',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScope(client: pg.PoolClient, tenantId: string, scopeType: string, scopeId: string): Promise<ExportScope> {
|
||||||
|
if (scopeType === 'collection') {
|
||||||
|
const result = await client.query<{
|
||||||
|
id: string;
|
||||||
|
regionId: string | null;
|
||||||
|
entryId: string | null;
|
||||||
|
entryName: string | null;
|
||||||
|
contentNodeId: string | null;
|
||||||
|
contentNodeName: string | null;
|
||||||
|
collectionName: string;
|
||||||
|
collectionType: string;
|
||||||
|
durationMinutes: number | null;
|
||||||
|
totalScore: string | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select qc.id, qc.region_id as "regionId",
|
||||||
|
qc.entry_id as "entryId", ce.name as "entryName",
|
||||||
|
qc.node_id as "contentNodeId", cn.name as "contentNodeName",
|
||||||
|
qc.name as "collectionName", qc.collection_type as "collectionType",
|
||||||
|
qc.duration_minutes as "durationMinutes", qc.total_score::text as "totalScore"
|
||||||
|
from public.question_collections qc
|
||||||
|
left join public.content_entries ce on ce.id = qc.entry_id and ce.tenant_id = qc.tenant_id
|
||||||
|
left join public.content_nodes cn on cn.id = qc.node_id and cn.tenant_id = qc.tenant_id
|
||||||
|
where qc.tenant_id = $1 and qc.id = $2 and qc.status = 'active'
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[tenantId, scopeId],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) throw new HttpError(404, 'Question collection not found', 'QUESTION_COLLECTION_NOT_FOUND');
|
||||||
|
return {
|
||||||
|
scopeType: 'collection',
|
||||||
|
scopeId,
|
||||||
|
regionId: row.regionId,
|
||||||
|
entryId: row.entryId,
|
||||||
|
entryName: row.entryName,
|
||||||
|
contentNodeId: row.contentNodeId,
|
||||||
|
contentNodeName: row.contentNodeName,
|
||||||
|
collectionId: row.id,
|
||||||
|
collectionName: row.collectionName,
|
||||||
|
collectionType: row.collectionType,
|
||||||
|
durationMinutes: row.durationMinutes,
|
||||||
|
totalScore: row.totalScore === null ? null : Number(row.totalScore),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeType === 'entry') {
|
||||||
|
const result = await client.query<{ id: string; regionId: string | null; name: string }>(
|
||||||
|
`
|
||||||
|
select id, region_id as "regionId", name
|
||||||
|
from public.content_entries
|
||||||
|
where tenant_id = $1 and id = $2 and entry_type = 'question_practice'
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[tenantId, scopeId],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) throw new HttpError(404, 'Content entry not found', 'CONTENT_ENTRY_NOT_FOUND');
|
||||||
|
return {
|
||||||
|
scopeType: 'entry',
|
||||||
|
scopeId,
|
||||||
|
regionId: row.regionId,
|
||||||
|
entryId: row.id,
|
||||||
|
entryName: row.name,
|
||||||
|
contentNodeId: null,
|
||||||
|
contentNodeName: null,
|
||||||
|
collectionId: null,
|
||||||
|
collectionName: null,
|
||||||
|
collectionType: null,
|
||||||
|
durationMinutes: null,
|
||||||
|
totalScore: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.query<{
|
||||||
|
id: string;
|
||||||
|
regionId: string | null;
|
||||||
|
name: string;
|
||||||
|
entryId: string;
|
||||||
|
entryName: string;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select cn.id, cn.region_id as "regionId", cn.name,
|
||||||
|
cn.entry_id as "entryId", ce.name as "entryName"
|
||||||
|
from public.content_nodes cn
|
||||||
|
join public.content_entries ce on ce.id = cn.entry_id and ce.tenant_id = cn.tenant_id
|
||||||
|
where cn.tenant_id = $1 and cn.id = $2
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[tenantId, scopeId],
|
||||||
|
);
|
||||||
|
const row = result.rows[0];
|
||||||
|
if (!row) throw new HttpError(404, 'Content node not found', 'CONTENT_NODE_NOT_FOUND');
|
||||||
|
return {
|
||||||
|
scopeType: 'content_node',
|
||||||
|
scopeId,
|
||||||
|
regionId: row.regionId,
|
||||||
|
entryId: row.entryId,
|
||||||
|
entryName: row.entryName,
|
||||||
|
contentNodeId: row.id,
|
||||||
|
contentNodeName: row.name,
|
||||||
|
collectionId: null,
|
||||||
|
collectionName: null,
|
||||||
|
collectionType: null,
|
||||||
|
durationMinutes: null,
|
||||||
|
totalScore: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQuestions(client: pg.PoolClient, tenantId: string, scopeType: string, scopeId: string, limit: number) {
|
||||||
|
const params: unknown[] = [tenantId, scopeId, limit];
|
||||||
|
let scopeFilter = '';
|
||||||
|
let orderBy = 'q.created_at asc';
|
||||||
|
if (scopeType === 'collection') {
|
||||||
|
scopeFilter = 'q.id in (select question_id from public.question_collection_items where tenant_id = $1 and collection_id = $2)';
|
||||||
|
orderBy = 'coalesce(ci.sort_order, 0) asc, q.created_at asc';
|
||||||
|
} else if (scopeType === 'entry') {
|
||||||
|
scopeFilter = 'q.entry_id = $2';
|
||||||
|
orderBy = 'coalesce(cn.path::text, q.created_at::text) asc, coalesce(ci.sort_order, 0) asc, q.created_at asc';
|
||||||
|
} else {
|
||||||
|
scopeFilter = `
|
||||||
|
(
|
||||||
|
q.content_node_id = $2
|
||||||
|
or q.content_node_id in (
|
||||||
|
select child.id
|
||||||
|
from public.content_nodes root
|
||||||
|
join public.content_nodes child on child.tenant_id = root.tenant_id and child.path <@ root.path
|
||||||
|
where root.tenant_id = $1 and root.id = $2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
orderBy = 'coalesce(cn.path::text, q.created_at::text) asc, coalesce(ci.sort_order, 0) asc, q.created_at asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.query<ExportQuestionRow>(
|
||||||
|
`
|
||||||
|
select q.id, q.legacy_id as "legacyId",
|
||||||
|
q.entry_id as "entryId", ce.name as "entryName",
|
||||||
|
q.content_node_id as "contentNodeId", cn.name as "contentNodeName", cn.path::text as "contentNodePath",
|
||||||
|
coalesce(ci.collection_id, q.primary_collection_id) as "collectionId", qc.name as "collectionName",
|
||||||
|
q.subject_id as "subjectId", s.name as "subjectName",
|
||||||
|
q.category_id as "categoryId", c.name as "categoryName",
|
||||||
|
q.question_bank_id as "questionBankId",
|
||||||
|
q.type, q.type_label as "typeLabel", q.difficulty, q.tags,
|
||||||
|
q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation",
|
||||||
|
ci.section_key as "sectionKey", ci.score, coalesce(ci.sort_order, 0) as "order",
|
||||||
|
v.id as "versionId", v.content, v.options,
|
||||||
|
v.correct_option_index as "correctOptionIndex",
|
||||||
|
v.correct_option_indices as "correctOptionIndices",
|
||||||
|
v.answer_text as "answerText", v.explanation, v.sub_questions as "subQuestions",
|
||||||
|
v.code_lang as "codeLang", v.code_template as "codeTemplate", v.source_hash as "sourceHash"
|
||||||
|
from public.questions q
|
||||||
|
left join public.question_collection_items ci
|
||||||
|
on ci.tenant_id = q.tenant_id
|
||||||
|
and ci.question_id = q.id
|
||||||
|
and (
|
||||||
|
($4::text = 'collection' and ci.collection_id = $2)
|
||||||
|
or ($4::text <> 'collection' and ci.collection_id = q.primary_collection_id)
|
||||||
|
)
|
||||||
|
left join public.question_collections qc on qc.id = coalesce(ci.collection_id, q.primary_collection_id) and qc.tenant_id = q.tenant_id
|
||||||
|
left join public.content_entries ce on ce.id = q.entry_id and ce.tenant_id = q.tenant_id
|
||||||
|
left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id
|
||||||
|
left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
|
||||||
|
left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id
|
||||||
|
left join public.question_versions v on v.id = q.current_version_id
|
||||||
|
where q.tenant_id = $1
|
||||||
|
and q.status = 'published'
|
||||||
|
and ${scopeFilter}
|
||||||
|
order by ${orderBy}
|
||||||
|
limit $3
|
||||||
|
`,
|
||||||
|
[...params, scopeType],
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createQuestionExportRoute(ctx: RequestContext) {
|
||||||
|
const auth = await requireTenantContentEditor(ctx);
|
||||||
|
const body = await readJsonBody(ctx);
|
||||||
|
const scopeType = choose(body.scopeType, SCOPE_TYPES, 'collection', 'INVALID_EXPORT_SCOPE') as ExportScope['scopeType'];
|
||||||
|
const scopeId = requiredString(body, 'scopeId');
|
||||||
|
const format = choose(body.format, EXPORT_FORMATS, 'json', 'INVALID_EXPORT_FORMAT');
|
||||||
|
const exportType = choose(body.exportType, EXPORT_TYPES, format === 'paper_json' ? 'paper' : 'questions', 'INVALID_EXPORT_TYPE');
|
||||||
|
const includeAnswers = boolValue(body.includeAnswers, true);
|
||||||
|
const includeExplanations = boolValue(body.includeExplanations, includeAnswers);
|
||||||
|
const includeVideoRefs = boolValue(body.includeVideoRefs, false);
|
||||||
|
const limit = exportLimit(body.limit);
|
||||||
|
const options = body.options && typeof body.options === 'object' && !Array.isArray(body.options) ? body.options as Record<string, unknown> : {};
|
||||||
|
|
||||||
|
const result = await transaction(async client => {
|
||||||
|
const scope = await loadScope(client, auth.tenantId, scopeType, scopeId);
|
||||||
|
const rows = await loadQuestions(client, auth.tenantId, scopeType, scopeId, limit);
|
||||||
|
const questions = rows.map(row => formatQuestion(row, includeAnswers, includeExplanations, includeVideoRefs));
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
|
||||||
|
const prePayload = {
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
scope,
|
||||||
|
exportType,
|
||||||
|
format,
|
||||||
|
includeAnswers,
|
||||||
|
includeExplanations,
|
||||||
|
includeVideoRefs,
|
||||||
|
questionCount: questions.length,
|
||||||
|
questions,
|
||||||
|
options,
|
||||||
|
createdAt,
|
||||||
|
};
|
||||||
|
const outputHash = contentHash(prePayload);
|
||||||
|
|
||||||
|
const job = await client.query<{
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
insert into public.content_export_jobs (
|
||||||
|
tenant_id, created_by, export_type, format, scope_type, scope_id,
|
||||||
|
status, include_answers, include_explanations, include_video_refs,
|
||||||
|
question_count, summary, options, output_metadata, finished_at
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
$1, $2, $3, $4, $5, $6,
|
||||||
|
'completed', $7, $8, $9,
|
||||||
|
$10, $11::jsonb, $12::jsonb, $13::jsonb, now()
|
||||||
|
)
|
||||||
|
returning id, created_at as "createdAt"
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
auth.tenantId,
|
||||||
|
auth.userId,
|
||||||
|
exportType,
|
||||||
|
format,
|
||||||
|
scopeType,
|
||||||
|
scopeId,
|
||||||
|
includeAnswers,
|
||||||
|
includeExplanations,
|
||||||
|
includeVideoRefs,
|
||||||
|
questions.length,
|
||||||
|
JSON.stringify({
|
||||||
|
questionCount: questions.length,
|
||||||
|
scopeName: scope.collectionName || scope.contentNodeName || scope.entryName,
|
||||||
|
format,
|
||||||
|
exportType,
|
||||||
|
}),
|
||||||
|
jsonObjectValue(options),
|
||||||
|
JSON.stringify({
|
||||||
|
outputHash,
|
||||||
|
delivery: 'inline_payload',
|
||||||
|
maxQuestionLimit: limit,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = buildExportPayload({
|
||||||
|
auth,
|
||||||
|
jobId: job.rows[0].id,
|
||||||
|
scope,
|
||||||
|
exportType,
|
||||||
|
format,
|
||||||
|
includeAnswers,
|
||||||
|
includeExplanations,
|
||||||
|
includeVideoRefs,
|
||||||
|
questions,
|
||||||
|
options,
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||||
|
values ($1, $2, 'content.questions.exported', 'content_export_job', $3, $4::jsonb)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
auth.tenantId,
|
||||||
|
auth.userId,
|
||||||
|
job.rows[0].id,
|
||||||
|
JSON.stringify({
|
||||||
|
scopeType,
|
||||||
|
scopeId,
|
||||||
|
format,
|
||||||
|
exportType,
|
||||||
|
questionCount: questions.length,
|
||||||
|
includeAnswers,
|
||||||
|
includeExplanations,
|
||||||
|
includeVideoRefs,
|
||||||
|
outputHash,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
job: {
|
||||||
|
id: job.rows[0].id,
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: job.rows[0].createdAt,
|
||||||
|
questionCount: questions.length,
|
||||||
|
outputHash,
|
||||||
|
},
|
||||||
|
export: payload,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function questionExportJobsRoute(ctx: RequestContext) {
|
||||||
|
const auth = await requireTenantContentEditor(ctx);
|
||||||
|
const scopeType = stringParam(ctx, 'scopeType');
|
||||||
|
const scopeId = stringParam(ctx, 'scopeId');
|
||||||
|
const limit = intParam(ctx, 'limit', 50, 200);
|
||||||
|
const params: unknown[] = [auth.tenantId];
|
||||||
|
const filters = ['tenant_id = $1'];
|
||||||
|
if (scopeType) {
|
||||||
|
if (!SCOPE_TYPES.includes(scopeType)) {
|
||||||
|
throw new HttpError(400, `${scopeType} is not supported`, 'INVALID_EXPORT_SCOPE');
|
||||||
|
}
|
||||||
|
params.push(scopeType);
|
||||||
|
filters.push(`scope_type = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (scopeId) {
|
||||||
|
params.push(scopeId);
|
||||||
|
filters.push(`scope_id = $${params.length}`);
|
||||||
|
}
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
const items = await query(
|
||||||
|
`
|
||||||
|
select id, export_type as "exportType", format, scope_type as "scopeType",
|
||||||
|
scope_id as "scopeId", status, include_answers as "includeAnswers",
|
||||||
|
include_explanations as "includeExplanations",
|
||||||
|
include_video_refs as "includeVideoRefs", question_count as "questionCount",
|
||||||
|
summary, options, output_metadata as "outputMetadata",
|
||||||
|
error_message as "errorMessage", finished_at as "finishedAt",
|
||||||
|
created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt"
|
||||||
|
from public.content_export_jobs
|
||||||
|
where ${filters.join(' and ')}
|
||||||
|
order by created_at desc
|
||||||
|
limit $${params.length}
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ import {
|
|||||||
importFieldMappingRoute,
|
importFieldMappingRoute,
|
||||||
importTemplateRoute,
|
importTemplateRoute,
|
||||||
} from './import-templates.js';
|
} from './import-templates.js';
|
||||||
|
import {
|
||||||
|
createQuestionExportRoute,
|
||||||
|
questionExportJobsRoute,
|
||||||
|
} from './exports.js';
|
||||||
import {
|
import {
|
||||||
contentEntriesAdminRoute,
|
contentEntriesAdminRoute,
|
||||||
contentNodesAdminRoute,
|
contentNodesAdminRoute,
|
||||||
@@ -108,6 +112,8 @@ export const tenantContentRoutes: RouteDefinition[] = [
|
|||||||
['GET', '/api/tenant-content/imports/templates', importTemplateRoute],
|
['GET', '/api/tenant-content/imports/templates', importTemplateRoute],
|
||||||
['POST', '/api/tenant-content/imports/post-check', importPostCheckRoute],
|
['POST', '/api/tenant-content/imports/post-check', importPostCheckRoute],
|
||||||
['GET', '/api/tenant-content/imports/post-check', importPostCheckStatusRoute],
|
['GET', '/api/tenant-content/imports/post-check', importPostCheckStatusRoute],
|
||||||
|
['POST', '/api/tenant-content/exports/questions', createQuestionExportRoute],
|
||||||
|
['GET', '/api/tenant-content/exports/jobs', questionExportJobsRoute],
|
||||||
['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],
|
||||||
|
|||||||
@@ -136,6 +136,7 @@
|
|||||||
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
||||||
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
|
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
|
||||||
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动同步,租户自改题目会标记冲突并跳过 |
|
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动同步,租户自改题目会标记冲突并跳过 |
|
||||||
|
| 题库导出基础 | 可联调 | `/api/tenant-content/exports/questions`、`/api/tenant-content/exports/jobs`;支持按题目集合、内容入口或分类节点导出 JSON/试卷 payload,后端校验租户内容编辑权限、跨租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word 二进制与水印 worker 后续补 |
|
||||||
|
|
||||||
## 销售、代理、CRM
|
## 销售、代理、CRM
|
||||||
|
|
||||||
@@ -165,6 +166,7 @@
|
|||||||
| 视频 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`;模板下载、字段映射 API 和导入后复检已接入 |
|
| 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,支持重试、清锁和审计 |
|
||||||
|
| 题库导出任务 | 可联调 | `content_export_jobs` 记录导出范围、格式、题量、输出 hash、选项和执行人;当前返回 inline base64 JSON 文件,前端可先下载 `.json` 或交给后续 PDF/Word worker 渲染 |
|
||||||
| 公共题库自动同步增强 | 待补齐 | 手动同步 API 已完成;后续需 worker 做定时同步、失败重试、版本升级通知、冲突操作台和批量确认/跳过 |
|
| 公共题库自动同步增强 | 待补齐 | 手动同步 API 已完成;后续需 worker 做定时同步、失败重试、版本升级通知、冲突操作台和批量确认/跳过 |
|
||||||
|
|
||||||
## 当前验证
|
## 当前验证
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 后端进度同步与前端接入路线图
|
# 后端进度同步与前端接入路线图
|
||||||
|
|
||||||
更新时间:2026-06-22
|
更新时间:2026-06-29
|
||||||
|
|
||||||
这份文档用于在进入 Taro 前端开发前,快速确认新 Supabase/PostgreSQL 后端已经做到哪里、还缺什么、前端应如何接入,以及后续继续开发的优先级。
|
这份文档用于在进入 Taro 前端开发前,快速确认新 Supabase/PostgreSQL 后端已经做到哪里、还缺什么、前端应如何接入,以及后续继续开发的优先级。
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
新项目已经不是简单的 PocketBase 字段平移,而是按多租户 SaaS 重新建立了后端边界:
|
新项目已经不是简单的 PocketBase 字段平移,而是按多租户 SaaS 重新建立了后端边界:
|
||||||
|
|
||||||
- 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。
|
- 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。
|
||||||
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册和资料资源。
|
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
|
||||||
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API。
|
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API。
|
||||||
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置和入队能力。
|
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置和入队能力。
|
||||||
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
|
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权 | 自动计费、平台审计、公共题库自动同步运营台 |
|
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权 | 自动计费、平台审计、公共题库自动同步运营台 |
|
||||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限 | 前端权限 UI、更细的数据范围组合 |
|
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限 | 前端权限 UI、更细的数据范围组合 |
|
||||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照和手动同步 | 专项策略、公共题库自动同步 worker/冲突操作台、排行榜防刷/预聚合 |
|
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照和手动同步、JSON/试卷 payload 导出 | 专项策略、PDF/Word 导出 worker、公共题库自动同步 worker/冲突操作台、排行榜防刷/预聚合 |
|
||||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 当前结论
|
## 当前结论
|
||||||
|
|
||||||
当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入基础闭环、异步导入 worker、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。
|
当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入基础闭环、异步导入 worker、题库导出基础、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。
|
||||||
|
|
||||||
但这还不是完整商用交付状态,也不能说旧项目核心功能已经全部重构完成。现在更准确的状态是:后端商用架构骨架已经立住,核心业务正在按模块补齐。部分功能已经有可调用 API,部分功能只有数据模型和导入映射,部分功能还没有前端/自动化测试闭环。
|
但这还不是完整商用交付状态,也不能说旧项目核心功能已经全部重构完成。现在更准确的状态是:后端商用架构骨架已经立住,核心业务正在按模块补齐。部分功能已经有可调用 API,部分功能只有数据模型和导入映射,部分功能还没有前端/自动化测试闭环。
|
||||||
|
|
||||||
@@ -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 集成测试含导航、组卷、导入、公共题库授权、采纳后组卷、同步新增题和租户自改冲突保护断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳/手动同步、导入后复检、模板下载和字段映射 API 可联调;公共题库自动同步 worker、版本通知和冲突操作台仍需补齐 |
|
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、异步导入 worker、平台公共题库授权、租户采纳快照和手动同步已实现 | 核心 API 集成测试含导航、组卷、导入、导出权限/脱敏、公共题库授权、采纳后组卷、同步新增题和租户自改冲突保护断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳/手动同步、导入后复检、模板下载、字段映射 API 和导出基础可联调;PDF/Word 导出 worker、公共题库自动同步 worker、版本通知和冲突操作台仍需补齐 |
|
||||||
| 错题本 | 已建 `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 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 |
|
||||||
@@ -151,6 +151,8 @@ tenant-content:
|
|||||||
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
|
||||||
|
POST /api/tenant-content/exports/questions
|
||||||
|
GET /api/tenant-content/exports/jobs
|
||||||
GET /api/tenant-content/videos
|
GET /api/tenant-content/videos
|
||||||
PUT /api/tenant-content/videos
|
PUT /api/tenant-content/videos
|
||||||
POST /api/tenant-content/question-videos
|
POST /api/tenant-content/question-videos
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
|
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
|
||||||
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 |
|
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 |
|
||||||
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载和字段映射 API 已有;字段映射 UI 和复检结果操作台待补 |
|
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载和字段映射 API 已有;字段映射 UI 和复检结果操作台待补 |
|
||||||
| 题库导出 PDF/Word/JSON | 未覆盖 | 旧前端有导出组件;新后端需决定服务端导出、导出水印和权限审计 |
|
| 题库导出 PDF/Word/JSON | 部分覆盖 | 服务端 JSON、`paper_json` 和打印 payload 导出基础已补,含租户内容编辑权限、跨租户拒绝、答案/解析开关、子题脱敏、导出 job 和审计;PDF/Word 二进制、水印、发布到资料下载和导出 worker 待补 |
|
||||||
| 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 |
|
| 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 |
|
||||||
| 背单词维护 | 已覆盖 | 单元/单词 CRUD 和导入已有 |
|
| 背单词维护 | 已覆盖 | 单元/单词 CRUD 和导入已有 |
|
||||||
| 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 |
|
| 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 |
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
|
|
||||||
1. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
|
1. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
|
||||||
2. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。
|
2. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。
|
||||||
3. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。
|
3. 题库导出:服务端 JSON/试卷 payload 导出、权限审计和答案脱敏已补;仍缺 PDF/Word 二进制生成、水印、资料发布和后台导出操作台。
|
||||||
4. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、模板下载和字段映射 API 已补,仍缺前端字段映射 UI 和真实数据 dry-run。
|
4. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、模板下载和字段映射 API 已补,仍缺前端字段映射 UI 和真实数据 dry-run。
|
||||||
5. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步和租户自改冲突保护已完成基础闭环;还需自动同步 worker、版本通知、冲突操作台和运营后台 UI。
|
5. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步和租户自改冲突保护已完成基础闭环;还需自动同步 worker、版本通知、冲突操作台和运营后台 UI。
|
||||||
6. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
|
6. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
|
||||||
@@ -126,4 +126,4 @@
|
|||||||
1. 主题模板、素材库、主题预览/发布。
|
1. 主题模板、素材库、主题预览/发布。
|
||||||
2. 班级、教师、学生分组和学习督导。
|
2. 班级、教师、学生分组和学习督导。
|
||||||
3. AI 择校推荐和 PDF 报告。
|
3. AI 择校推荐和 PDF 报告。
|
||||||
4. 题库导出、试卷生成、每日一练运营工具。
|
4. 题库导出二进制生成、试卷排版 worker、每日一练运营工具。
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
- 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。
|
- 模考报告与学习统计:`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 快照保护。
|
- 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。
|
||||||
- 内容导入:题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、校验、导入、幂等、审计。
|
- 内容导入:题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、校验、导入、幂等、审计。
|
||||||
|
- 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。
|
||||||
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||||
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;后续补批量 CRM 推送和自动学习督导。
|
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;后续补批量 CRM 推送和自动学习督导。
|
||||||
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。
|
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。
|
||||||
@@ -76,50 +77,55 @@
|
|||||||
|
|
||||||
3. 导入体系扩展
|
3. 导入体系扩展
|
||||||
- 已完成题目、单词、知识手册、分数线、视频的 CSV/Excel 到规范 JSON 解析适配。
|
- 已完成题目、单词、知识手册、分数线、视频的 CSV/Excel 到规范 JSON 解析适配。
|
||||||
- 继续补大批量导入异步 worker、重试、导入后校验。
|
- 已完成大批量异步导入 worker、重试、导入后复检和审计。
|
||||||
- 模板下载、字段映射 API 和导入后复检已补;继续补字段映射 UI、真实数据 dry-run、导入前抽样校验和复检结果操作台。
|
- 模板下载、字段映射 API 和导入后复检已补;继续补字段映射 UI、真实数据 dry-run、导入前抽样校验和复检结果操作台。
|
||||||
|
|
||||||
4. 公共题库和租户授权
|
4. 题库导出增强
|
||||||
|
- 已完成服务端 JSON、`paper_json`、`print_payload` 基础导出和导出 job 台账。
|
||||||
|
- 当前前端可先下载 inline base64 JSON,或把 `paper_json` 交给后台打印预览组件渲染。
|
||||||
|
- 继续补 PDF/Word 二进制生成 worker、页面水印、导出发布到资料下载、导出模板和操作台。
|
||||||
|
|
||||||
|
5. 公共题库和租户授权
|
||||||
- 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。
|
- 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。
|
||||||
- 继续补按 SaaS 套餐限制地区数量、科目范围、题库范围的更细计费策略。
|
- 继续补按 SaaS 套餐限制地区数量、科目范围、题库范围的更细计费策略。
|
||||||
- 继续补公共题库自动同步 worker、同步失败重试、版本通知、冲突操作台和运营后台 UI。
|
- 继续补公共题库自动同步 worker、同步失败重试、版本通知、冲突操作台和运营后台 UI。
|
||||||
|
|
||||||
5. 视频会员控制
|
6. 视频会员控制
|
||||||
- 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。
|
- 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。
|
||||||
- 继续补深度防盗链、动态水印、播放统计。
|
- 继续补深度防盗链、动态水印、播放统计。
|
||||||
- 单题视频和通用知识视频混合推荐。
|
- 单题视频和通用知识视频混合推荐。
|
||||||
|
|
||||||
6. 学习统计
|
7. 学习统计
|
||||||
- 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。
|
- 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。
|
||||||
- 已完成单词复习算法、每日计划和复习上报。
|
- 已完成单词复习算法、每日计划和复习上报。
|
||||||
- 已完成排行榜主接口;继续补防刷、日/周榜预聚合和运营后台排名看板。
|
- 已完成排行榜主接口;继续补防刷、日/周榜预聚合和运营后台排名看板。
|
||||||
- 继续补断点续练和复盘体验。
|
- 继续补断点续练和复盘体验。
|
||||||
|
|
||||||
7. 订单和营销体验
|
8. 订单和营销体验
|
||||||
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算和内部退款状态机。
|
||||||
- 已完成支付/退款补偿 worker;继续补完整资金流水对账、异常订单运营台、优惠券核销报表和复杂活动规则。
|
- 已完成支付/退款补偿 worker;继续补完整资金流水对账、异常订单运营台、优惠券核销报表和复杂活动规则。
|
||||||
|
|
||||||
8. 积分和反馈增强
|
9. 积分和反馈增强
|
||||||
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
||||||
- 已完成勋章后台维护、手动发放和学生端展示。
|
- 已完成勋章后台维护、手动发放和学生端展示。
|
||||||
- 继续补积分兑换、活动任务、连续签到奖励配置、勋章自动发放、处理通知和反馈聚合统计。
|
- 继续补积分兑换、活动任务、连续签到奖励配置、勋章自动发放、处理通知和反馈聚合统计。
|
||||||
|
|
||||||
9. 数据看板
|
10. 数据看板
|
||||||
- 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。
|
- 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。
|
||||||
- 继续补日/周/月预聚合 worker、缓存策略、慢 SQL 监控和大租户性能压测。
|
- 继续补日/周/月预聚合 worker、缓存策略、慢 SQL 监控和大租户性能压测。
|
||||||
- 分佣结算基础闭环已完成;继续补销售/代理转化、结算导出、真实打款、凭证和客资跟进效果看板。
|
- 分佣结算基础闭环已完成;继续补销售/代理转化、结算导出、真实打款、凭证和客资跟进效果看板。
|
||||||
|
|
||||||
10. 销售/代理分佣
|
11. 销售/代理分佣
|
||||||
- 已完成租户默认分佣比例、成员分佣比例、激活码批次分佣比例。
|
- 已完成租户默认分佣比例、成员分佣比例、激活码批次分佣比例。
|
||||||
- 已完成订单和激活码两类来源的佣金归因,且只统计客资绑定后的成交。
|
- 已完成订单和激活码两类来源的佣金归因,且只统计客资绑定后的成交。
|
||||||
- 已完成结算单生成、重复结算拦截、审核、打款状态、已打款锁定、租户隔离和销售/代理本人范围权限。
|
- 已完成结算单生成、重复结算拦截、审核、打款状态、已打款锁定、租户隔离和销售/代理本人范围权限。
|
||||||
- 继续补结算导出、真实打款 provider、发票/凭证、财务复核流、销售团队分佣看板和异常调整单。
|
- 继续补结算导出、真实打款 provider、发票/凭证、财务复核流、销售团队分佣看板和异常调整单。
|
||||||
|
|
||||||
11. 学生运营管理
|
12. 学生运营管理
|
||||||
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。
|
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。
|
||||||
- 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和前端 UI。
|
- 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和前端 UI。
|
||||||
|
|
||||||
12. AI 择校推荐
|
13. AI 择校推荐
|
||||||
- 地区考试数据上下文。
|
- 地区考试数据上下文。
|
||||||
- 学生输入 schema。
|
- 学生输入 schema。
|
||||||
- AI 返回 JSON schema。
|
- AI 返回 JSON schema。
|
||||||
@@ -206,5 +212,5 @@
|
|||||||
2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||||
3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||||
4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。
|
4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。
|
||||||
5. 并行补对象存储、真实登录、完整资金流水对账、题库导出和公共题库自动同步 worker/冲突操作台。
|
5. 并行补对象存储、真实登录、完整资金流水对账、题库导出 PDF/Word worker 和公共题库自动同步 worker/冲突操作台。
|
||||||
6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Taro 前端对接指南
|
# Taro 前端对接指南
|
||||||
|
|
||||||
更新时间:2026-06-28
|
更新时间:2026-06-29
|
||||||
|
|
||||||
目标:用一套 Taro 工程同时服务微信小程序和 H5 Web 题库,并采用“Supabase Auth/JWT + `apps/api` 业务 API 优先”的混合架构,支持多租户、品牌主题、地区题库、会员权益、销售追踪和对象存储资源。
|
目标:用一套 Taro 工程同时服务微信小程序和 H5 Web 题库,并采用“Supabase Auth/JWT + `apps/api` 业务 API 优先”的混合架构,支持多租户、品牌主题、地区题库、会员权益、销售追踪和对象存储资源。
|
||||||
|
|
||||||
@@ -185,6 +185,7 @@ tenant:<tenantId>:theme
|
|||||||
| 租户反馈处理 | `GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` |
|
| 租户反馈处理 | `GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` |
|
||||||
| 租户勋章 | `GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants` |
|
| 租户勋章 | `GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants` |
|
||||||
| 公共题库采纳/同步 | `GET /api/tenant-content/public-question-banks`、`POST /api/tenant-content/public-question-banks/adopt`、`POST /api/tenant-content/public-question-banks/sync` |
|
| 公共题库采纳/同步 | `GET /api/tenant-content/public-question-banks`、`POST /api/tenant-content/public-question-banks/adopt`、`POST /api/tenant-content/public-question-banks/sync` |
|
||||||
|
| 题库导出 | `POST /api/tenant-content/exports/questions`、`GET /api/tenant-content/exports/jobs` |
|
||||||
|
|
||||||
## 练习访问控制契约
|
## 练习访问控制契约
|
||||||
|
|
||||||
@@ -817,6 +818,105 @@ failed:导入结果和目标表不一致,必须提示管理员排查,不
|
|||||||
- 绑定题目必须提供 `questionId` 或 `legacyQuestionId`。
|
- 绑定题目必须提供 `questionId` 或 `legacyQuestionId`。
|
||||||
- 生产建议把私有视频先入 `content_assets`,导入时传 `assetId`,避免长期暴露源站 URL。
|
- 生产建议把私有视频先入 `content_assets`,导入时传 `assetId`,避免长期暴露源站 URL。
|
||||||
|
|
||||||
|
## 题库导出对接
|
||||||
|
|
||||||
|
租户后台题库导出统一走后端生成结构化 payload,前端不要直接查 Supabase 表拼导出文件。当前后端已支持 JSON、`paper_json` 和 `print_payload` 三类基础导出,适合先做后台“导出 JSON/试卷预览”功能;PDF/Word 二进制、水印和发布到资料下载后续由 worker 增强。
|
||||||
|
|
||||||
|
可用接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/tenant-content/exports/questions
|
||||||
|
GET /api/tenant-content/exports/jobs
|
||||||
|
```
|
||||||
|
|
||||||
|
导出范围:
|
||||||
|
|
||||||
|
| scopeType | scopeId | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `collection` | `question_collections.id` | 导出某个题目列表或试卷集合 |
|
||||||
|
| `entry` | `content_entries.id` | 导出某个题库入口下全部已发布题目 |
|
||||||
|
| `content_node` | `content_nodes.id` | 导出某个分类节点及其子节点下全部已发布题目 |
|
||||||
|
|
||||||
|
普通题库 JSON 导出:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scopeType": "collection",
|
||||||
|
"scopeId": "<questionCollectionId>",
|
||||||
|
"format": "json",
|
||||||
|
"exportType": "questions",
|
||||||
|
"includeAnswers": false,
|
||||||
|
"includeExplanations": false,
|
||||||
|
"options": {
|
||||||
|
"title": "天津专升本题库导出"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
试卷 payload 导出:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scopeType": "content_node",
|
||||||
|
"scopeId": "<contentNodeId>",
|
||||||
|
"format": "paper_json",
|
||||||
|
"exportType": "paper",
|
||||||
|
"includeAnswers": true,
|
||||||
|
"includeExplanations": true,
|
||||||
|
"options": {
|
||||||
|
"title": "全真模拟试卷",
|
||||||
|
"durationMinutes": 120,
|
||||||
|
"watermarkText": "仅供内部使用"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应关键结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"job": {
|
||||||
|
"id": "...",
|
||||||
|
"status": "completed",
|
||||||
|
"questionCount": 1,
|
||||||
|
"outputHash": "..."
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"_tikuExport": "3.0",
|
||||||
|
"jobId": "...",
|
||||||
|
"summary": {
|
||||||
|
"questionCount": 1,
|
||||||
|
"sectionCount": 1
|
||||||
|
},
|
||||||
|
"sections": [],
|
||||||
|
"questions": [],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"filename": "天津专升本题库导出.json",
|
||||||
|
"mimeType": "application/json",
|
||||||
|
"encoding": "base64",
|
||||||
|
"contentBase64": "..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"renderHints": {
|
||||||
|
"pdfLayout": "paper",
|
||||||
|
"pageSize": "A4",
|
||||||
|
"answerPlacement": "inline_or_appendix"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端处理规则:
|
||||||
|
|
||||||
|
- 导出按钮只给具备租户内容编辑权限的后台成员展示;接口仍以后端 `TENANT_CONTENT_EDITOR_REQUIRED` 为准。
|
||||||
|
- 下载 JSON 时使用 `files[0].contentBase64` 生成 Blob,文件名使用后端返回的 `filename`。
|
||||||
|
- `includeAnswers=false` 时,顶层答案字段和阅读理解/案例分析的子题答案都会被后端脱敏;前端不要在本地重新合并答案。
|
||||||
|
- `includeExplanations=false` 时,不展示解析,也不要从题目详情接口额外补解析。
|
||||||
|
- `paper_json` 可先用于后台试卷预览和打印;正式 PDF/Word 导出等后端 worker 完成后再接二进制文件下载。
|
||||||
|
- `GET /api/tenant-content/exports/jobs?scopeType=collection&scopeId=...` 用于后台导出历史;当前记录 metadata 和输出 hash,不长期保存完整导出内容。
|
||||||
|
- 跨租户导出会返回 404 或 403,前端不要重试其它租户 ID。
|
||||||
|
|
||||||
## 公共题库采纳对接
|
## 公共题库采纳对接
|
||||||
|
|
||||||
平台超级管理员后台使用:
|
平台超级管理员后台使用:
|
||||||
|
|||||||
@@ -2253,6 +2253,16 @@ async function testTenantContentAdmin() {
|
|||||||
correctOptionIndices: [1],
|
correctOptionIndices: [1],
|
||||||
answerText: '4',
|
answerText: '4',
|
||||||
explanation: '基础加法。',
|
explanation: '基础加法。',
|
||||||
|
subQuestions: [
|
||||||
|
{
|
||||||
|
type: 'choice',
|
||||||
|
content: '集成测试子题:1 + 1 = ?',
|
||||||
|
options: ['1', '2'],
|
||||||
|
correctOptionIndices: [1],
|
||||||
|
answerText: '2',
|
||||||
|
explanation: '子题基础加法。',
|
||||||
|
},
|
||||||
|
],
|
||||||
status: 'published',
|
status: 'published',
|
||||||
examMarkers: { schoolName: '集成测试学院', salesIntent: true },
|
examMarkers: { schoolName: '集成测试学院', salesIntent: true },
|
||||||
},
|
},
|
||||||
@@ -2312,6 +2322,118 @@ async function testTenantContentAdmin() {
|
|||||||
});
|
});
|
||||||
assert.ok(publicCreatedCollections.items?.some(item => item.id === collection.item.id), 'catalog should expose created collection');
|
assert.ok(publicCreatedCollections.items?.some(item => item.id === collection.item.id), 'catalog should expose created collection');
|
||||||
|
|
||||||
|
const exportDenied = await request('/api/tenant-content/exports/questions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { scopeType: 'collection', scopeId: collection.item.id },
|
||||||
|
expectStatus: 403,
|
||||||
|
});
|
||||||
|
assert.equal(exportDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not export tenant question bank content');
|
||||||
|
|
||||||
|
const exportNoAnswers = await request('/api/tenant-content/exports/questions', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
scopeType: 'collection',
|
||||||
|
scopeId: collection.item.id,
|
||||||
|
format: 'json',
|
||||||
|
exportType: 'questions',
|
||||||
|
includeAnswers: false,
|
||||||
|
includeExplanations: false,
|
||||||
|
options: { title: '集成测试无答案题库导出' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(exportNoAnswers.job?.status, 'completed', 'question export should create a completed export job');
|
||||||
|
assert.equal(exportNoAnswers.export?.summary?.questionCount, 1, 'question export should include collection questions');
|
||||||
|
assert.ok(exportNoAnswers.export?.files?.[0]?.contentBase64, 'question export should return a downloadable JSON payload');
|
||||||
|
assert.equal(
|
||||||
|
Object.hasOwn(exportNoAnswers.export?.questions?.[0] || {}, 'answerText'),
|
||||||
|
false,
|
||||||
|
'question export should omit answers when includeAnswers is false',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
Object.hasOwn(exportNoAnswers.export?.questions?.[0] || {}, 'explanation'),
|
||||||
|
false,
|
||||||
|
'question export should omit explanations when includeExplanations is false',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
Object.hasOwn(exportNoAnswers.export?.questions?.[0]?.subQuestions?.[0] || {}, 'correctOptionIndices'),
|
||||||
|
false,
|
||||||
|
'question export should omit sub question answers when includeAnswers is false',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
Object.hasOwn(exportNoAnswers.export?.questions?.[0]?.subQuestions?.[0] || {}, 'answerText'),
|
||||||
|
false,
|
||||||
|
'question export should omit sub question answer text when includeAnswers is false',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
Object.hasOwn(exportNoAnswers.export?.questions?.[0]?.subQuestions?.[0] || {}, 'explanation'),
|
||||||
|
false,
|
||||||
|
'question export should omit sub question explanations when includeExplanations is false',
|
||||||
|
);
|
||||||
|
const decodedExport = JSON.parse(Buffer.from(exportNoAnswers.export.files[0].contentBase64, 'base64').toString('utf8'));
|
||||||
|
assert.equal(decodedExport._tikuExport, '3.0', 'question export JSON should use the new export contract');
|
||||||
|
assert.equal(decodedExport.questions?.[0]?.content, '集成测试题:2 + 2 = ?', 'question export JSON should include question content');
|
||||||
|
|
||||||
|
const exportBadLimit = await request('/api/tenant-content/exports/questions', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
scopeType: 'collection',
|
||||||
|
scopeId: collection.item.id,
|
||||||
|
limit: 'not-a-number',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(exportBadLimit.export?.summary?.questionCount, 1, 'question export should tolerate invalid limit input with a safe default');
|
||||||
|
|
||||||
|
const paperExport = await request('/api/tenant-content/exports/questions', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
scopeType: 'content_node',
|
||||||
|
scopeId: childNode.item.id,
|
||||||
|
format: 'paper_json',
|
||||||
|
exportType: 'paper',
|
||||||
|
includeAnswers: true,
|
||||||
|
includeExplanations: true,
|
||||||
|
options: {
|
||||||
|
title: '集成测试试卷版导出',
|
||||||
|
durationMinutes: 90,
|
||||||
|
watermarkText: '仅供内部测试',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(paperExport.export?.renderHints?.pdfLayout, 'paper', 'paper export should expose paper render hints');
|
||||||
|
assert.ok(
|
||||||
|
paperExport.export?.sections?.some(section => section.questions?.some(item => item.id === question.item.id)),
|
||||||
|
'paper export should group the created question by section',
|
||||||
|
);
|
||||||
|
const exportedQuestionWithAnswer = paperExport.export?.questions?.find(item => item.id === question.item.id);
|
||||||
|
assert.equal(exportedQuestionWithAnswer?.answerText, '4', 'paper export should include answers when requested');
|
||||||
|
|
||||||
|
const partnerExportDenied = await request('/api/tenant-content/exports/questions', {
|
||||||
|
tenantId: PARTNER_TENANT_ID,
|
||||||
|
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: { scopeType: 'collection', scopeId: collection.item.id },
|
||||||
|
expectStatus: 404,
|
||||||
|
});
|
||||||
|
assert.equal(partnerExportDenied.code, 'QUESTION_COLLECTION_NOT_FOUND', 'question export must not read another tenant collection');
|
||||||
|
|
||||||
|
const exportJobs = await request('/api/tenant-content/exports/jobs', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
query: { scopeType: 'collection', scopeId: collection.item.id, limit: 10 },
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
exportJobs.items?.some(item => item.id === exportNoAnswers.job.id && item.questionCount === 1),
|
||||||
|
'question export jobs should list completed export audit records',
|
||||||
|
);
|
||||||
|
const invalidExportJobsScope = await request('/api/tenant-content/exports/jobs', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
query: { scopeType: 'not_supported' },
|
||||||
|
expectStatus: 400,
|
||||||
|
});
|
||||||
|
assert.equal(invalidExportJobsScope.code, 'INVALID_EXPORT_SCOPE', 'question export jobs should validate scope type filters');
|
||||||
|
|
||||||
const createdBlueprintSession = await request('/api/learning/practice-sessions', {
|
const createdBlueprintSession = await request('/api/learning/practice-sessions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { userId: USER_ID, blueprintId: blueprint.item.id },
|
body: { userId: USER_ID, blueprintId: blueprint.item.id },
|
||||||
|
|||||||
44
supabase/migrations/202606290015_content_export_jobs.sql
Normal file
44
supabase/migrations/202606290015_content_export_jobs.sql
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
create table if not exists public.content_export_jobs (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||||
|
created_by uuid references public.platform_users(id) on delete set null,
|
||||||
|
export_type text not null default 'questions'
|
||||||
|
check (export_type in ('questions', 'paper', 'daily_practice')),
|
||||||
|
format text not null default 'json'
|
||||||
|
check (format in ('json', 'paper_json', 'print_payload')),
|
||||||
|
scope_type text not null
|
||||||
|
check (scope_type in ('collection', 'entry', 'content_node')),
|
||||||
|
scope_id uuid not null,
|
||||||
|
status text not null default 'completed'
|
||||||
|
check (status in ('completed', 'failed')),
|
||||||
|
include_answers boolean not null default true,
|
||||||
|
include_explanations boolean not null default true,
|
||||||
|
include_video_refs boolean not null default false,
|
||||||
|
question_count integer not null default 0 check (question_count >= 0),
|
||||||
|
summary jsonb not null default '{}'::jsonb,
|
||||||
|
options jsonb not null default '{}'::jsonb,
|
||||||
|
output_metadata jsonb not null default '{}'::jsonb,
|
||||||
|
error_message text,
|
||||||
|
finished_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_content_export_jobs_tenant_created
|
||||||
|
on public.content_export_jobs(tenant_id, created_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_content_export_jobs_scope
|
||||||
|
on public.content_export_jobs(tenant_id, scope_type, scope_id, created_at desc);
|
||||||
|
|
||||||
|
alter table public.content_export_jobs enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists tenant_isolation on public.content_export_jobs;
|
||||||
|
create policy tenant_isolation on public.content_export_jobs
|
||||||
|
for all
|
||||||
|
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||||
|
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||||
|
|
||||||
|
drop trigger if exists set_updated_at on public.content_export_jobs;
|
||||||
|
create trigger set_updated_at
|
||||||
|
before update on public.content_export_jobs
|
||||||
|
for each row execute function app.touch_updated_at();
|
||||||
Reference in New Issue
Block a user