forked from wangziqi/gongxue-base
feat: resolve public bank conflicts
This commit is contained in:
@@ -48,6 +48,7 @@ import {
|
||||
adoptPublicQuestionBankRoute,
|
||||
publicQuestionBankConflictsRoute,
|
||||
publicQuestionBanksRoute,
|
||||
resolvePublicQuestionBankConflictRoute,
|
||||
syncPublicQuestionBankRoute,
|
||||
} from './public-banks.js';
|
||||
import {
|
||||
@@ -82,6 +83,7 @@ export const tenantContentRoutes: RouteDefinition[] = [
|
||||
['POST', '/api/tenant-content/public-question-banks/adopt', adoptPublicQuestionBankRoute],
|
||||
['POST', '/api/tenant-content/public-question-banks/sync', syncPublicQuestionBankRoute],
|
||||
['GET', '/api/tenant-content/public-question-banks/conflicts', publicQuestionBankConflictsRoute],
|
||||
['POST', '/api/tenant-content/public-question-banks/conflicts/resolve', resolvePublicQuestionBankConflictRoute],
|
||||
['PUT', '/api/tenant-content/content-entries', upsertContentEntryRoute],
|
||||
['GET', '/api/tenant-content/content-nodes', contentNodesAdminRoute],
|
||||
['PUT', '/api/tenant-content/content-nodes', upsertContentNodeRoute],
|
||||
|
||||
@@ -70,6 +70,7 @@ interface SyncQuestionResult {
|
||||
sourceHash: string;
|
||||
previousSourceHash: string | null;
|
||||
targetHash: string | null;
|
||||
resolution?: 'keep_local';
|
||||
}
|
||||
|
||||
interface PublicQuestionBankSyncAuth {
|
||||
@@ -263,6 +264,32 @@ function snapshotMap(value: unknown): Record<string, string> {
|
||||
return result;
|
||||
}
|
||||
|
||||
function sourceSnapshotObject(value: unknown) {
|
||||
const snapshot = objectValue(value);
|
||||
return {
|
||||
...snapshot,
|
||||
questions: objectValue(snapshot.questions),
|
||||
};
|
||||
}
|
||||
|
||||
function setSourceSnapshotQuestion(snapshot: Record<string, unknown> & { questions: Record<string, unknown> }, sourceQuestionId: string, sourceHash: string, extra: Record<string, unknown> = {}) {
|
||||
snapshot.questions[sourceQuestionId] = {
|
||||
...objectValue(snapshot.questions[sourceQuestionId]),
|
||||
...extra,
|
||||
sourceHash,
|
||||
syncedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function lastSyncConflicts(metadata: Record<string, unknown>) {
|
||||
const lastSync = objectValue(metadata.lastSync);
|
||||
return Array.isArray(lastSync.conflicts) ? lastSync.conflicts.map(item => objectValue(item)) : [];
|
||||
}
|
||||
|
||||
function resolvedPublicBankConflicts(metadata: Record<string, unknown>) {
|
||||
return objectValue(metadata.resolvedPublicBankConflicts);
|
||||
}
|
||||
|
||||
async function sourceQuestionSnapshots(client: pg.PoolClient, input: {
|
||||
sourceTenantId: string;
|
||||
sourceQuestionBankId: string;
|
||||
@@ -336,6 +363,7 @@ async function syncQuestionSnapshot(client: pg.PoolClient, input: {
|
||||
targetCollectionId: string;
|
||||
source: SourceQuestionSnapshot;
|
||||
previousSourceHash: string | null;
|
||||
conflictResolutions?: Record<string, unknown>;
|
||||
order: number;
|
||||
}) {
|
||||
const legacyId = `public:${input.sourceTenantId}:${input.source.id}`;
|
||||
@@ -360,6 +388,22 @@ async function syncQuestionSnapshot(client: pg.PoolClient, input: {
|
||||
const targetMatchesCurrent = !!targetHash && targetHash === input.source.sourceHash;
|
||||
const targetMatchesPrevious = !!targetHash && !!input.previousSourceHash && targetHash === input.previousSourceHash;
|
||||
if (!targetMatchesCurrent && !targetMatchesPrevious) {
|
||||
const resolved = objectValue(input.conflictResolutions?.[input.source.id]);
|
||||
if (
|
||||
resolved.decision === 'keep_local'
|
||||
&& resolved.sourceHash === input.source.sourceHash
|
||||
&& resolved.targetHash === targetHash
|
||||
) {
|
||||
return {
|
||||
sourceQuestionId: input.source.id,
|
||||
targetQuestionId: existingQuestion.id,
|
||||
action: 'skipped',
|
||||
sourceHash: input.source.sourceHash,
|
||||
previousSourceHash: input.previousSourceHash,
|
||||
targetHash,
|
||||
resolution: 'keep_local',
|
||||
} satisfies SyncQuestionResult;
|
||||
}
|
||||
return {
|
||||
sourceQuestionId: input.source.id,
|
||||
targetQuestionId: existingQuestion.id,
|
||||
@@ -568,6 +612,7 @@ async function syncQuestionsSnapshot(client: pg.PoolClient, input: {
|
||||
targetCollectionId: string;
|
||||
copyLimit: number;
|
||||
previousSnapshot?: unknown;
|
||||
conflictResolutions?: Record<string, unknown>;
|
||||
}) {
|
||||
const sources = await sourceQuestionSnapshots(client, input);
|
||||
const previous = snapshotMap(input.previousSnapshot);
|
||||
@@ -583,6 +628,7 @@ async function syncQuestionsSnapshot(client: pg.PoolClient, input: {
|
||||
targetCollectionId: input.targetCollectionId,
|
||||
source,
|
||||
previousSourceHash: previous[source.id] || null,
|
||||
conflictResolutions: input.conflictResolutions,
|
||||
order,
|
||||
});
|
||||
results.push(result);
|
||||
@@ -952,6 +998,7 @@ export async function executePublicQuestionBankSync(input: PublicQuestionBankSyn
|
||||
targetCollectionId: adoption.targetCollectionId,
|
||||
copyLimit,
|
||||
previousSnapshot: adoption.sourceSnapshot,
|
||||
conflictResolutions: resolvedPublicBankConflicts(adoption.metadata || {}),
|
||||
});
|
||||
const conflicts = syncResult.results.filter(item => item.action === 'conflict');
|
||||
const syncStatus = conflicts.length ? 'failed' : 'synced';
|
||||
@@ -1114,3 +1161,266 @@ export async function publicQuestionBankConflictsRoute(ctx: RequestContext) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolvePublicQuestionBankConflictRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const adoptionId = requiredString(body, 'adoptionId');
|
||||
const sourceQuestionId = requiredString(body, 'sourceQuestionId');
|
||||
const resolution = requiredString(body, 'resolution');
|
||||
if (!['accept_platform', 'keep_local'].includes(resolution)) {
|
||||
throw new HttpError(400, 'resolution must be accept_platform or keep_local', 'PUBLIC_BANK_CONFLICT_RESOLUTION_INVALID');
|
||||
}
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const adoptionResult = await client.query<AdoptionRow>(
|
||||
`
|
||||
select id, tenant_id as "tenantId",
|
||||
source_question_bank_id as "sourceQuestionBankId",
|
||||
grant_id as "grantId",
|
||||
target_question_bank_id as "targetQuestionBankId",
|
||||
target_entry_id as "targetEntryId",
|
||||
target_collection_id as "targetCollectionId",
|
||||
adoption_mode as "adoptionMode", status,
|
||||
sync_status as "syncStatus",
|
||||
source_snapshot as "sourceSnapshot",
|
||||
copied_question_count as "copiedQuestionCount",
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.tenant_question_bank_adoptions
|
||||
where tenant_id = $1 and id = $2 and status <> 'archived'
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[auth.tenantId, adoptionId],
|
||||
);
|
||||
const adoption = adoptionResult.rows[0];
|
||||
if (!adoption) throw new HttpError(404, 'Question bank adoption not found', 'QUESTION_BANK_ADOPTION_NOT_FOUND');
|
||||
if (!adoption.targetQuestionBankId || !adoption.targetEntryId || !adoption.targetCollectionId) {
|
||||
throw new HttpError(409, 'Question bank adoption target is incomplete', 'QUESTION_BANK_ADOPTION_TARGET_MISSING');
|
||||
}
|
||||
|
||||
const lastConflicts = lastSyncConflicts(adoption.metadata || {});
|
||||
const conflict = lastConflicts.find(item => item.sourceQuestionId === sourceQuestionId);
|
||||
if (!conflict) {
|
||||
throw new HttpError(404, 'Public question bank conflict not found', 'PUBLIC_BANK_CONFLICT_NOT_FOUND');
|
||||
}
|
||||
const targetQuestionId = nullableString(conflict.targetQuestionId);
|
||||
if (!targetQuestionId) {
|
||||
throw new HttpError(409, 'Conflict has no target question', 'PUBLIC_BANK_CONFLICT_TARGET_MISSING');
|
||||
}
|
||||
|
||||
const grant = adoption.grantId ? await loadEligibleGrant(client, auth.tenantId, adoption.grantId) : null;
|
||||
if (!grant || grant.sourceQuestionBankId !== adoption.sourceQuestionBankId) {
|
||||
throw new HttpError(403, 'Question bank grant is not available for this tenant', 'QUESTION_BANK_GRANT_NOT_AVAILABLE');
|
||||
}
|
||||
const source = (await sourceQuestionSnapshots(client, {
|
||||
sourceTenantId: grant.sourceTenantId,
|
||||
sourceQuestionBankId: grant.sourceQuestionBankId,
|
||||
copyLimit: 1000,
|
||||
})).find(question => question.id === sourceQuestionId);
|
||||
if (!source) throw new HttpError(404, 'Source question not found', 'PUBLIC_BANK_SOURCE_QUESTION_NOT_FOUND');
|
||||
|
||||
const target = await client.query<{ id: string; source_hash: string | null }>(
|
||||
`
|
||||
select q.id, v.source_hash
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = $2
|
||||
limit 1
|
||||
for update of q
|
||||
`,
|
||||
[auth.tenantId, targetQuestionId],
|
||||
);
|
||||
if (!target.rows[0]) throw new HttpError(404, 'Target question not found', 'PUBLIC_BANK_TARGET_QUESTION_NOT_FOUND');
|
||||
|
||||
const snapshot = sourceSnapshotObject(adoption.sourceSnapshot);
|
||||
const metadata: Record<string, unknown> = {
|
||||
...(adoption.metadata || {}),
|
||||
resolvedPublicBankConflicts: {
|
||||
...resolvedPublicBankConflicts(adoption.metadata || {}),
|
||||
},
|
||||
};
|
||||
const resolved = objectValue(metadata.resolvedPublicBankConflicts);
|
||||
|
||||
if (resolution === 'accept_platform') {
|
||||
const latest = await client.query<{ version_no: number }>(
|
||||
'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
|
||||
[targetQuestionId],
|
||||
);
|
||||
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
|
||||
const version = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.question_versions (
|
||||
tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text,
|
||||
explanation, sub_questions, code_lang, code_template,
|
||||
source_hash, created_by
|
||||
)
|
||||
values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
targetQuestionId,
|
||||
nextVersionNo,
|
||||
source.content,
|
||||
JSON.stringify(source.options || []),
|
||||
source.correctOptionIndex,
|
||||
JSON.stringify(source.correctOptionIndices || []),
|
||||
source.answerText,
|
||||
source.explanation,
|
||||
JSON.stringify(source.subQuestions || []),
|
||||
source.codeLang,
|
||||
source.codeTemplate,
|
||||
source.sourceHash,
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
update public.questions
|
||||
set question_bank_id = $3,
|
||||
entry_id = $4,
|
||||
primary_collection_id = $5,
|
||||
type = $6,
|
||||
type_label = $7,
|
||||
difficulty = $8,
|
||||
tags = $9::jsonb,
|
||||
media_url = $10,
|
||||
has_video_explanation = $11,
|
||||
status = 'published',
|
||||
current_version_id = $12,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
targetQuestionId,
|
||||
adoption.targetQuestionBankId,
|
||||
adoption.targetEntryId,
|
||||
adoption.targetCollectionId,
|
||||
source.type,
|
||||
source.typeLabel,
|
||||
source.difficulty,
|
||||
JSON.stringify(source.tags || []),
|
||||
source.mediaUrl,
|
||||
source.hasVideoExplanation,
|
||||
version.rows[0].id,
|
||||
],
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
insert into public.question_collection_items (
|
||||
tenant_id, collection_id, question_id, section_key, sort_order, score, required, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, null, true, $6::jsonb)
|
||||
on conflict (tenant_id, collection_id, question_id)
|
||||
do update set section_key = excluded.section_key,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
adoption.targetCollectionId,
|
||||
targetQuestionId,
|
||||
source.type,
|
||||
0,
|
||||
JSON.stringify({
|
||||
source: 'public_question_bank_adoption',
|
||||
sourceQuestionId,
|
||||
lastConflictResolution: 'accept_platform',
|
||||
}),
|
||||
],
|
||||
);
|
||||
setSourceSnapshotQuestion(snapshot, sourceQuestionId, source.sourceHash, { resolution: 'accept_platform' });
|
||||
delete resolved[sourceQuestionId];
|
||||
} else {
|
||||
setSourceSnapshotQuestion(snapshot, sourceQuestionId, source.sourceHash, { resolution: 'keep_local' });
|
||||
resolved[sourceQuestionId] = {
|
||||
decision: 'keep_local',
|
||||
sourceHash: source.sourceHash,
|
||||
targetHash: target.rows[0].source_hash,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
resolvedBy: auth.userId,
|
||||
};
|
||||
}
|
||||
|
||||
const remainingConflicts = lastConflicts.filter(item => item.sourceQuestionId !== sourceQuestionId);
|
||||
const lastSync = {
|
||||
...objectValue((adoption.metadata || {}).lastSync),
|
||||
status: remainingConflicts.length ? 'failed' : 'resolved',
|
||||
conflictCount: remainingConflicts.length,
|
||||
conflicts: remainingConflicts,
|
||||
lastResolution: {
|
||||
sourceQuestionId,
|
||||
resolution,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
metadata.resolvedPublicBankConflicts = resolved;
|
||||
metadata.lastSync = lastSync;
|
||||
|
||||
const updated = await client.query<AdoptionRow>(
|
||||
`
|
||||
update public.tenant_question_bank_adoptions
|
||||
set sync_status = $3,
|
||||
source_snapshot = $4::jsonb,
|
||||
metadata = $5::jsonb,
|
||||
updated_by = $6,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
returning id, tenant_id as "tenantId",
|
||||
source_question_bank_id as "sourceQuestionBankId",
|
||||
grant_id as "grantId",
|
||||
target_question_bank_id as "targetQuestionBankId",
|
||||
target_entry_id as "targetEntryId",
|
||||
target_collection_id as "targetCollectionId",
|
||||
adoption_mode as "adoptionMode", status,
|
||||
sync_status as "syncStatus",
|
||||
source_snapshot as "sourceSnapshot",
|
||||
copied_question_count as "copiedQuestionCount",
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
adoption.id,
|
||||
remainingConflicts.length ? 'failed' : 'synced',
|
||||
JSON.stringify(snapshot),
|
||||
JSON.stringify(metadata),
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, 'content.public_question_bank.conflict_resolved', 'tenant_question_bank_adoption', $3, $4::jsonb)
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
auth.userId,
|
||||
adoption.id,
|
||||
JSON.stringify({
|
||||
sourceQuestionBankId: adoption.sourceQuestionBankId,
|
||||
sourceQuestionId,
|
||||
targetQuestionId,
|
||||
resolution,
|
||||
sourceHash: source.sourceHash,
|
||||
previousTargetHash: target.rows[0].source_hash,
|
||||
remainingConflictCount: remainingConflicts.length,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
adoption: updated.rows[0],
|
||||
sourceQuestionId,
|
||||
targetQuestionId,
|
||||
resolution,
|
||||
remainingConflictCount: remainingConflicts.length,
|
||||
syncStatus: updated.rows[0].syncStatus,
|
||||
};
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
@@ -59,12 +59,12 @@ pages/student/profile/index 个人中心、会员、订单、签到、激
|
||||
pages/tenant-admin/workbench/index 租户后台工作台
|
||||
pages/tenant-admin/dashboard/index 数据看板
|
||||
pages/tenant-admin/students/index 学生与班级
|
||||
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、复检、公共题库采纳/同步
|
||||
pages/tenant-admin/content/index 内容入口、导入任务、字段模板、复检、公共题库采纳/同步/冲突处理
|
||||
pages/tenant-admin/marketing/index 优惠券、激活码、CRM、分佣摘要
|
||||
pages/tenant-admin/settings/index 品牌、域名、支付、登录、角色模板
|
||||
```
|
||||
|
||||
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、导入问题查看、模板预览、导入后复检,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
||||
当前后台页面已经从只读联调推进到第一批运营写操作。题库内容页已接入公共题库采纳、公共题库同步、同步冲突查看、单条采纳平台版本/保留本地版本、导入问题查看、模板预览、导入后复检,以及 JSON/CSV/Excel 的 H5 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入第一版;营销、设置和学生页仍以扫描和轻量操作为主。真正权限以后端 permission keys 为准,前端菜单隐藏只做体验优化。
|
||||
|
||||
## 当前平台后台页面
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
loadPublicQuestionBankConflicts,
|
||||
loadPublicQuestionBanks,
|
||||
previewContentImport,
|
||||
resolvePublicQuestionBankConflict,
|
||||
runImportPostCheck,
|
||||
syncPublicQuestionBank,
|
||||
type ContentEntryAdminItem,
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
type ImportTemplateItem,
|
||||
type ImportType,
|
||||
type ImportSourceFormat,
|
||||
type PublicQuestionBankConflictItem,
|
||||
type PublicQuestionBankConflictsResult,
|
||||
type PublicQuestionBankItem,
|
||||
} from '@/services/tenantAdmin';
|
||||
import '../admin.css';
|
||||
@@ -100,6 +103,10 @@ function safeJsonPreview(value: unknown, maxLength = 360) {
|
||||
}
|
||||
}
|
||||
|
||||
function conflictItems(value: PublicQuestionBankConflictsResult | null) {
|
||||
return Array.isArray(value?.conflicts) ? value.conflicts : [];
|
||||
}
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
@@ -131,7 +138,7 @@ export default function TenantContentPage() {
|
||||
const [template, setTemplate] = useState<ImportTemplateItem | null>(null);
|
||||
const [previewResult, setPreviewResult] = useState<ImportPreviewResult | null>(null);
|
||||
const [postCheck, setPostCheck] = useState<Record<string, unknown> | null>(null);
|
||||
const [conflicts, setConflicts] = useState<Record<string, unknown> | null>(null);
|
||||
const [conflicts, setConflicts] = useState<PublicQuestionBankConflictsResult | null>(null);
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -393,6 +400,38 @@ export default function TenantContentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConflict(item: PublicQuestionBankConflictItem, resolution: 'accept_platform' | 'keep_local') {
|
||||
const adoptionId = conflicts?.adoptionId;
|
||||
if (!adoptionId || !item.sourceQuestionId) {
|
||||
setError('缺少冲突上下文,无法处理。');
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(
|
||||
resolution === 'accept_platform' ? '采纳平台版本' : '保留租户版本',
|
||||
resolution === 'accept_platform'
|
||||
? '确认用平台公共题库的新版本覆盖该租户副本?系统会写入新题目版本并记录审计。'
|
||||
: '确认保留租户本地改写版本?系统会记录该冲突已处理,后续同步不再反复提示同一版本。',
|
||||
);
|
||||
if (!ok) return;
|
||||
setBusy(`resolve:${item.sourceQuestionId}:${resolution}`);
|
||||
setError('');
|
||||
try {
|
||||
await resolvePublicQuestionBankConflict({
|
||||
adoptionId,
|
||||
sourceQuestionId: item.sourceQuestionId,
|
||||
resolution,
|
||||
});
|
||||
const payload = await loadPublicQuestionBankConflicts(adoptionId);
|
||||
setConflicts(payload.item || null);
|
||||
Taro.showToast({ title: '已处理', icon: 'success' });
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '冲突处理失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
const postCheckSummary = postCheck?.importPostCheck && typeof postCheck.importPostCheck === 'object'
|
||||
? postCheck.importPostCheck as Record<string, unknown>
|
||||
: postCheck;
|
||||
@@ -564,10 +603,24 @@ export default function TenantContentPage() {
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>同步冲突</Text>
|
||||
{conflicts ? (
|
||||
<View className='admin-row'>
|
||||
<Text className='admin-row-main'>状态 {String(conflicts.status || conflicts.syncStatus || '-')}</Text>
|
||||
<Text className='admin-row-meta'>冲突数 {String(conflicts.conflictCount || 0)} · 最近同步 {String(conflicts.lastSyncedAt || '')}</Text>
|
||||
<Text className='admin-row-meta'>{JSON.stringify(conflicts).slice(0, 300)}</Text>
|
||||
<View className='admin-list'>
|
||||
<View className='admin-row'>
|
||||
<Text className='admin-row-main'>状态 {String(conflicts.status || conflicts.syncStatus || '-')}</Text>
|
||||
<Text className='admin-row-meta'>冲突数 {String(conflicts.conflictCount || 0)} · 最近同步 {String(conflicts.lastSyncedAt || '')}</Text>
|
||||
<Text className='admin-row-meta'>采纳平台会覆盖为平台新版;保留本地会记录本次冲突已处理。</Text>
|
||||
</View>
|
||||
{conflictItems(conflicts).slice(0, 20).map((item, index) => (
|
||||
<View className='admin-row' key={`${item.sourceQuestionId || index}-${item.targetQuestionId || ''}`}>
|
||||
<Text className='admin-row-main'>题目冲突 {String(item.sourceQuestionId || '').slice(0, 8)}</Text>
|
||||
<Text className='admin-row-meta'>目标 {String(item.targetQuestionId || '-').slice(0, 8)} · 平台 {String(item.sourceHash || '').slice(0, 10)} · 本地 {String(item.targetHash || '').slice(0, 10)}</Text>
|
||||
<Text className='admin-row-meta'>上次平台 {String(item.previousSourceHash || '-').slice(0, 10)}</Text>
|
||||
<View className='admin-row-actions'>
|
||||
<Button className='admin-mini-button primary' loading={busy === `resolve:${item.sourceQuestionId}:accept_platform`} onClick={() => resolveConflict(item, 'accept_platform')}>采纳平台</Button>
|
||||
<Button className='admin-mini-button' loading={busy === `resolve:${item.sourceQuestionId}:keep_local`} onClick={() => resolveConflict(item, 'keep_local')}>保留本地</Button>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
{!conflictItems(conflicts).length ? <View className='admin-empty'>当前没有待处理冲突。</View> : null}
|
||||
</View>
|
||||
) : <View className='admin-empty'>同步后或点击“冲突”可查看最近冲突摘要。</View>}
|
||||
</View>
|
||||
|
||||
@@ -85,6 +85,26 @@ export interface PublicQuestionBankItem {
|
||||
adoption?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface PublicQuestionBankConflictItem {
|
||||
sourceQuestionId?: string;
|
||||
targetQuestionId?: string | null;
|
||||
action?: string;
|
||||
sourceHash?: string;
|
||||
previousSourceHash?: string | null;
|
||||
targetHash?: string | null;
|
||||
}
|
||||
|
||||
export interface PublicQuestionBankConflictsResult {
|
||||
adoptionId?: string;
|
||||
syncStatus?: string;
|
||||
status?: string;
|
||||
lastSyncedAt?: string | null;
|
||||
updatedAt?: string;
|
||||
conflictCount?: number;
|
||||
counts?: Record<string, unknown>;
|
||||
conflicts?: PublicQuestionBankConflictItem[];
|
||||
}
|
||||
|
||||
export interface ImportIssueItem {
|
||||
id: string;
|
||||
rowNo?: number | null;
|
||||
@@ -250,11 +270,18 @@ export async function syncPublicQuestionBank(input: { adoptionId: string; copyLi
|
||||
}
|
||||
|
||||
export async function loadPublicQuestionBankConflicts(adoptionId: string) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/public-question-banks/conflicts', {
|
||||
return apiRequest<{ item?: PublicQuestionBankConflictsResult }>('/api/tenant-content/public-question-banks/conflicts', {
|
||||
query: { adoptionId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolvePublicQuestionBankConflict(input: { adoptionId: string; sourceQuestionId: string; resolution: 'accept_platform' | 'keep_local' }) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/public-question-banks/conflicts/resolve', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runImportPostCheck(jobId: string) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/imports/post-check', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
| 平台租户/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*` |
|
||||
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
||||
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
|
||||
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,租户自改题目会标记冲突并跳过,后台可查询最近一次冲突明细 |
|
||||
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条选择“采纳平台版本”或“保留本地版本”,操作会写入审计 |
|
||||
| 题库导出基础 | 可联调 | `/api/tenant-content/exports/questions`、`/api/tenant-content/exports/jobs`;支持按题目集合、内容入口或分类节点导出 JSON/试卷 payload,后端校验租户内容编辑权限、跨租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word 二进制与水印 worker 后续补 |
|
||||
|
||||
## 销售、代理、CRM
|
||||
@@ -161,7 +161,7 @@
|
||||
| PocketBase schema/导出分析 | 可联调 | `scripts/import-pocketbase` 支持 schema summary/risk、`npm run pb:import:dry-run` 导出目录静态迁移报告 |
|
||||
| PocketBase JSON dry-run | 可联调 | 不写数据库,检查导出目录、JSON 形态、核心集合、旧 ID、敏感字段、schema relation、未映射集合和关键业务计数 |
|
||||
| 题目 JSON preview/import | 可联调 | 后端负责规范化、issue、幂等、审计 |
|
||||
| 公共题库采纳、手动同步和自动同步 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;同步 API 和 `public-banks` worker 支持新增/更新题目、重新校验授权、跨租户拒绝、审计记录和租户自改冲突保护;已覆盖跨租户、重复采纳、采纳后组卷、同步新增题、冲突不覆盖和 worker 自动同步测试 |
|
||||
| 公共题库采纳、手动同步和自动同步 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;同步 API 和 `public-banks` worker 支持新增/更新题目、重新校验授权、跨租户拒绝、审计记录和租户自改冲突保护;冲突处理 API 已支持单条采纳平台版本和保留租户本地版本;已覆盖跨租户、重复采纳、采纳后组卷、同步新增题、冲突不覆盖、冲突处理和 worker 自动同步测试 |
|
||||
| 单词 JSON preview/import | 可联调 | 兼容旧模板 |
|
||||
| 知识手册 JSON preview/import | 可联调 | 支持书籍/章节/小节/知识点归一化 |
|
||||
| 分数线 JSON preview/import | 可联调 | 支持 `fields/schools/majors/records` 分桶或 `items` 列表,后端校验租户地区和院校/专业引用 |
|
||||
@@ -169,7 +169,7 @@
|
||||
| Excel/CSV 导入 | 可联调 | 题目、单词、知识手册、分数线、视频已支持 CSV 和 `.xlsx` 解析,解析后复用 `content_import_jobs/items/issues` 管线并保留 `parser_metadata`;模板下载、字段映射 API、字段映射覆盖白名单、导入后复检已接入;Taro 租户内容页已接上传/粘贴 preview/import 和字段别名编辑第一版 |
|
||||
| 大批量异步导入 | 可联调 | `executionMode=async` 会将 preview job 置为 `pending`;`apps/worker --job imports` 抢占 queued job,复用 API 导入 executor,支持重试、清锁和审计 |
|
||||
| 题库导出任务 | 可联调 | `content_export_jobs` 记录导出范围、格式、题量、输出 hash、选项和执行人;当前返回 inline base64 JSON 文件,前端可先下载 `.json` 或交给后续 PDF/Word worker 渲染 |
|
||||
| 公共题库自动同步增强 | 部分覆盖 | `apps/worker --job public-banks` 已可抢占待同步采纳记录、自动同步平台新增/更新题目、记录失败和审计;后续需接入生产定时调度、版本升级通知、冲突操作台和批量确认/跳过 |
|
||||
| 公共题库自动同步增强 | 部分覆盖 | `apps/worker --job public-banks` 已可抢占待同步采纳记录、自动同步平台新增/更新题目、记录失败和审计;租户后台已有单条冲突处理第一版;后续需接入生产定时调度、版本升级通知、批量确认/跳过和更完整运营消息 |
|
||||
|
||||
## 当前验证
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
| 模块 | 当前状态 | 已经具备 | 上线前还要补 |
|
||||
| --- | --- | --- | --- |
|
||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker | 自动计费、平台审计、公共题库版本通知和冲突处理运营台 |
|
||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker | 自动计费、平台审计、公共题库版本通知和批量处理运营台 |
|
||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限 | 前端权限 UI、更细的数据范围组合 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询 API、JSON/试卷 payload 导出 | 专项策略、PDF/Word 导出 worker、公共题库版本通知和冲突操作台、排行榜防刷/预聚合 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/处理 API、JSON/试卷 payload 导出 | 专项策略、PDF/Word 导出 worker、公共题库版本通知和批量处理、排行榜防刷/预聚合 |
|
||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
||||
@@ -35,7 +35,7 @@
|
||||
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、字段别名编辑和同步/异步执行导入第一版 | 异步 job 轮询、模板文件下载按钮、复检结果详情、真实数据 dry-run 执行验收和导入性能压测 |
|
||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||
| AI 择校推荐 | 未开始 | 暂无 | 数据上下文、AI JSON schema、报告渲染、PDF 生成 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户内容页已接公共题库采纳/同步、导入问题、字段模板、上传/粘贴预览、字段别名覆盖、同步/异步导入和复检第一版 | 刷题细节 UI、租户后台异步导入轮询/模板下载/复检详情/冲突处理详情、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条采纳平台/保留本地、导入问题、字段模板、上传/粘贴预览、字段别名覆盖、同步/异步导入和复检第一版 | 刷题细节 UI、租户后台异步导入轮询/模板下载/复检详情/冲突批量处理、平台后台审计/详情增强、小程序兼容验证和端到端测试 |
|
||||
|
||||
## 前端接入建议
|
||||
|
||||
@@ -79,14 +79,14 @@
|
||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名和 assets worker 复检已完成,继续补 PDF 渲染、视频播放防盗链、杀毒扫描和水印。
|
||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run`,再跑迁移和校验报告。
|
||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户内容页已接公共题库采纳/同步、导入问题、字段模板、上传/粘贴预览、字段别名覆盖、同步/异步导入和复检第一版;下一步补刷题细节 UI、租户后台异步导入轮询/模板下载/复检详情/冲突处理详情、平台后台审计增强和小程序兼容验证。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,平台后台关键写操作第一版已接入,租户内容页已接公共题库采纳/同步、冲突查看、单条采纳平台/保留本地、导入问题、字段模板、上传/粘贴预览、字段别名覆盖、同步/异步导入和复检第一版;下一步补刷题细节 UI、租户后台异步导入轮询/模板下载/复检详情/冲突批量处理、平台后台审计增强和小程序兼容验证。
|
||||
|
||||
### P1:商用收费和运营能力
|
||||
|
||||
- 完整资金流水对账、账单下载比对和异常订单运营台。
|
||||
- XPay 或其它实际支付网关 adapter。
|
||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。
|
||||
- 公共题库/地区题库自动同步 worker 已具备单批执行能力;继续补版本通知、冲突操作台,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
|
||||
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有单条冲突采纳平台/保留本地操作;继续补版本通知、批量处理,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
|
||||
- 导入模板、字段映射和复检 API 已可用;Taro 租户内容页已接字段别名覆盖和导入执行第一版。前端继续补模板下载按钮、job 状态轮询、复检结果面板和真实导入目标选择体验。
|
||||
- 视频深度防盗链、动态水印和播放统计。
|
||||
- 数据看板 API:收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
## 当前可进入的前端工作
|
||||
|
||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;题库内容页已具备公共题库采纳/同步、冲突查看、导入问题查看、模板预览、导入后复检、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;题库内容页已具备公共题库采纳/同步、冲突查看、单条采纳平台版本/保留本地版本、导入问题查看、模板预览、导入后复检、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力。
|
||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:刷题细节、勋章展示和小程序端分享/支付体验。地区选择、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||
- 可以按新后端主模型接入内容导航:
|
||||
@@ -87,11 +87,11 @@
|
||||
| 工作台 | `apps/taro/src/pages/tenant-admin/workbench/index.tsx` | `tenant-admin/overview`、`tenant-admin/dashboard` |
|
||||
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` |
|
||||
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/students` |
|
||||
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts` |
|
||||
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve` |
|
||||
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/queue`、`commission/summary` |
|
||||
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`role-templates` |
|
||||
|
||||
当前租户后台已有第一批运营操作:题库内容页支持公共题库采纳/同步、同步冲突查看、导入问题查看、字段映射/模板预览、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检。下一批需要继续补完整后台写入表单、异步导入轮询、模板文件下载按钮、复检结果详情、学生批量导入、角色模板配置 UI 和权限驱动菜单。
|
||||
当前租户后台已有第一批运营操作:题库内容页支持公共题库采纳/同步、同步冲突查看、单条采纳平台版本/保留本地版本、导入问题查看、字段映射/模板预览、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检。下一批需要继续补完整后台写入表单、异步导入轮询、模板文件下载按钮、复检结果详情、冲突批量处理、学生批量导入、角色模板配置 UI 和权限驱动菜单。
|
||||
|
||||
## 已落地的 Taro 平台后台页面
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ tenant-content:
|
||||
POST /api/tenant-content/public-question-banks/adopt
|
||||
POST /api/tenant-content/public-question-banks/sync
|
||||
GET /api/tenant-content/public-question-banks/conflicts
|
||||
POST /api/tenant-content/public-question-banks/conflicts/resolve
|
||||
GET /api/tenant-content/content-nodes
|
||||
PUT /api/tenant-content/content-nodes
|
||||
GET /api/tenant-content/question-collections
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
| SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 |
|
||||
| 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 |
|
||||
| 租户用量记录 | 已覆盖 | 自动采集 worker 待补 |
|
||||
| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、冲突查询 API 和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺版本通知、冲突处理操作台、批量接受/保留策略 |
|
||||
| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、冲突查询 API、单条冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺版本通知、批量接受/保留策略和更完整运营消息 |
|
||||
| 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 |
|
||||
| 租户安全审计 | 部分覆盖 | audit logs 有;缺平台级审计报表 |
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
2. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。
|
||||
3. 题库导出:服务端 JSON/试卷 payload 导出、权限审计和答案脱敏已补;仍缺 PDF/Word 二进制生成、水印、资料发布和后台导出操作台。
|
||||
4. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、模板下载、字段映射 API、Taro 字段别名编辑和 PocketBase JSON dry-run 报告已补,仍缺异步轮询、模板下载按钮、复检详情和真实数据执行验收。
|
||||
5. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、冲突查询和租户自改冲突保护已完成基础闭环;还需版本通知、冲突处理操作台和运营后台 UI。
|
||||
5. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、冲突查询、租户自改冲突保护和单条冲突处理已完成基础闭环;还需版本通知、批量处理和运营后台消息。
|
||||
6. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
|
||||
7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
|
||||
8. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。
|
||||
@@ -119,7 +119,7 @@
|
||||
2. 对象存储 PDF 预览、视频深度防盗链、动态水印。
|
||||
3. 异步导入轮询、模板下载按钮、真实数据 dry-run 执行验收和导入复检结果操作台。
|
||||
4. 数据看板预聚合 worker、销售/代理转化看板和分佣结算。
|
||||
5. 公共题库版本通知、冲突处理操作台和租户确认/跳过策略。
|
||||
5. 公共题库版本通知、冲突批量处理、租户确认/跳过策略和运营消息。
|
||||
|
||||
### P2:增强体验
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。
|
||||
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;后续补自动发放规则和活动联动。
|
||||
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
||||
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细。
|
||||
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条选择采纳平台版本或保留本地版本。
|
||||
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
||||
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
|
||||
- 内容资源复检 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,正常资源写回复检证据,异常资源自动置为 `failed + draft` 并写入审计和安全标记。
|
||||
@@ -88,9 +88,9 @@
|
||||
|
||||
5. 公共题库和租户授权
|
||||
- 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。
|
||||
- 已完成 `public-banks` worker 自动同步、失败记录、审计和冲突查询 API。
|
||||
- 已完成 `public-banks` worker 自动同步、失败记录、审计、冲突查询 API 和单条冲突处理 API。
|
||||
- 继续补按 SaaS 套餐限制地区数量、科目范围、题库范围的更细计费策略。
|
||||
- 继续补生产定时调度、版本通知、冲突处理操作台、批量接受/保留策略和运营后台 UI。
|
||||
- 继续补生产定时调度、版本通知、批量接受/保留策略和运营后台消息。
|
||||
|
||||
6. 视频会员控制
|
||||
- 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。
|
||||
@@ -212,10 +212,10 @@
|
||||
|
||||
## 推荐下一步顺序
|
||||
|
||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、导入问题、模板预览、上传/粘贴 preview/import、字段映射编辑和导入后复检已接第一版;继续补异步导入轮询、模板文件下载、复检详情、冲突处理详情、角色模板、CRM/分佣。
|
||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条冲突采纳平台/保留本地、导入问题、模板预览、上传/粘贴 preview/import、字段映射编辑和导入后复检已接第一版;继续补异步导入轮询、模板文件下载、复检详情、冲突批量处理、角色模板、CRM/分佣。
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补刷题细节 UI、小程序支付容器、分享场景和状态管理。
|
||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||
6. 并行补对象存储、真实登录、完整资金流水对账、题库导出 PDF/Word worker 和公共题库版本通知/冲突操作台。
|
||||
6. 并行补对象存储、真实登录、完整资金流水对账、题库导出 PDF/Word worker 和公共题库版本通知/批量处理。
|
||||
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
|
||||
@@ -184,7 +184,7 @@ tenant:<tenantId>:theme
|
||||
| 租户考试日期 | `GET/PUT /api/tenant-admin/exam-dates` |
|
||||
| 租户反馈处理 | `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 /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/conflicts?adoptionId=...` |
|
||||
| 公共题库采纳/同步 | `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/conflicts?adoptionId=...`、`POST /api/tenant-content/public-question-banks/conflicts/resolve` |
|
||||
| 题库导出 | `POST /api/tenant-content/exports/questions`、`GET /api/tenant-content/exports/jobs` |
|
||||
|
||||
## 练习访问控制契约
|
||||
@@ -975,6 +975,7 @@ 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/conflicts?adoptionId=...
|
||||
POST /api/tenant-content/public-question-banks/conflicts/resolve
|
||||
```
|
||||
|
||||
采纳请求:
|
||||
@@ -1043,11 +1044,12 @@ GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...
|
||||
|
||||
- `sync.status=synced`:刷新公共题库列表、题目集合和题目列表。
|
||||
- `sync.status=conflict` 或 `item.syncStatus=failed`:展示冲突数量和冲突题目,不要把它当系统异常。冲突表示租户已经改过这道采纳题,后端已跳过并保留租户内容。
|
||||
- `action=conflict` 的记录可以进入后续“冲突处理”页面:展示平台源题 ID、租户目标题 ID、上次平台 hash、当前平台 hash、租户当前 hash。当前后端只负责保护不覆盖,批量接受平台版本/保留租户版本的操作台后续补。
|
||||
- `action=conflict` 的记录可以进入冲突处理区域:展示平台源题 ID、租户目标题 ID、上次平台 hash、当前平台 hash、租户当前 hash。当前后端已支持单条处理,批量接受平台版本/保留租户版本后续补。
|
||||
- 页面初始化或 worker 后台同步完成后,可以调用 `GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...` 查询最近一次同步状态、`counts` 和 `conflicts`。这个接口只返回当前租户自己的采纳记录,跨租户会返回 `QUESTION_BANK_ADOPTION_NOT_FOUND`。
|
||||
- 单条冲突处理调用 `POST /api/tenant-content/public-question-banks/conflicts/resolve`,body 为 `{ "adoptionId": "...", "sourceQuestionId": "...", "resolution": "accept_platform | keep_local" }`。`accept_platform` 会把租户副本写成平台当前版本并生成新题目版本;`keep_local` 会记录本地保留决策,同一平台 hash 和本地 hash 后续同步不再反复提示。两种操作都会写审计日志。
|
||||
- `QUESTION_BANK_GRANT_NOT_AVAILABLE`:说明 SaaS 套餐/授权已失效,提示联系平台或升级套餐。
|
||||
- `QUESTION_BANK_ADOPTION_NOT_FOUND`:说明不是当前租户的采纳记录或记录已归档,前端不要跨租户重试。
|
||||
- 后续会补版本通知、冲突操作台和批量确认策略;当前租户后台可以先提供手动“同步平台更新”按钮,并展示 worker 自动同步后的冲突查询结果。
|
||||
- 后续会补版本通知和批量确认策略;当前租户后台可以先提供手动“同步平台更新”按钮,并展示 worker 自动同步后的冲突查询结果和单条处理按钮。
|
||||
|
||||
## 登录对接
|
||||
|
||||
@@ -1404,7 +1406,7 @@ src/services/learning.ts 练习 session、答题、收藏、错题/收藏复
|
||||
src/services/commerce.ts 套餐、优惠券、下单、支付参数、订单详情、权益、激活码
|
||||
src/services/profile.ts 个人中心、地区目标、签到、反馈、勋章、倒计时
|
||||
src/services/video.ts 题目视频列表、播放签名
|
||||
src/services/tenantAdmin.ts 租户后台看板、学生、内容、营销、设置、公共题库采纳/同步、导入复检
|
||||
src/services/tenantAdmin.ts 租户后台看板、学生、内容、营销、设置、公共题库采纳/同步/冲突处理、导入复检
|
||||
src/services/platformAdmin.ts 平台后台租户、套餐账单、用量、公共题库授权
|
||||
```
|
||||
|
||||
@@ -1422,7 +1424,7 @@ npm run build:taro:h5:platform
|
||||
下一批前端开发重点:
|
||||
|
||||
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。
|
||||
- 租户后台:题库内容页已接公共题库采纳/同步、冲突查看、导入问题、模板预览和导入后复检第一版;下一批继续补完整写入表单、导入上传 preview/import 操作台、字段映射编辑、学生批量导入、角色模板配置 UI、CRM 分配和分佣结算操作。
|
||||
- 租户后台:题库内容页已接公共题库采纳/同步、冲突查看、单条采纳平台/保留本地、导入问题、模板预览和导入后复检第一版;下一批继续补完整写入表单、导入上传 preview/import 操作台、字段映射编辑、冲突批量处理、学生批量导入、角色模板配置 UI、CRM 分配和分佣结算操作。
|
||||
- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。
|
||||
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
|
||||
|
||||
|
||||
@@ -4088,6 +4088,126 @@ async function testPublicQuestionBankAdoption() {
|
||||
const conflictTargetAfter = collectionAfterConflict.items?.find(item => item.id === conflictTarget.id);
|
||||
assert.equal(conflictTargetAfter?.content, tenantCustomContent, 'public bank sync must not overwrite tenant-customized copied question');
|
||||
|
||||
const crossTenantResolveDenied = await request('/api/tenant-content/public-question-banks/conflicts/resolve', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
adoptionId: adopted.item.id,
|
||||
sourceQuestionId: ids.question,
|
||||
resolution: 'accept_platform',
|
||||
},
|
||||
expectStatus: 404,
|
||||
});
|
||||
assert.equal(crossTenantResolveDenied.code, 'QUESTION_BANK_ADOPTION_NOT_FOUND', 'public bank conflict resolution must be tenant isolated');
|
||||
|
||||
const acceptedConflict = await request('/api/tenant-content/public-question-banks/conflicts/resolve', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
adoptionId: adopted.item.id,
|
||||
sourceQuestionId: ids.question,
|
||||
resolution: 'accept_platform',
|
||||
},
|
||||
});
|
||||
assert.equal(acceptedConflict.item?.resolution, 'accept_platform', 'tenant admin should accept platform version for a conflict');
|
||||
assert.equal(acceptedConflict.item?.remainingConflictCount, 0, 'accepting the only conflict should clear conflict count');
|
||||
assert.equal(acceptedConflict.item?.syncStatus, 'synced', 'accepted conflict should mark adoption synced when no conflicts remain');
|
||||
|
||||
const collectionAfterAccept = await request('/api/catalog/question-collections/questions', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: false,
|
||||
query: { collectionId: adopted.item.targetCollectionId, limit: 50 },
|
||||
});
|
||||
const acceptedTarget = collectionAfterAccept.items?.find(item => item.id === conflictTarget.id);
|
||||
assert.equal(acceptedTarget?.content, '平台公共题库更新:1 + 1 = ?', 'accepting platform conflict should update tenant copy to platform content');
|
||||
|
||||
const conflictListAfterAccept = await request('/api/tenant-content/public-question-banks/conflicts', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
query: { adoptionId: adopted.item.id },
|
||||
});
|
||||
assert.equal(conflictListAfterAccept.item?.conflictCount, 0, 'resolved conflict list should have no remaining conflicts');
|
||||
|
||||
const keepLocalContent = `租户再次保留本地公共题副本 ${Date.now()}`;
|
||||
await request('/api/tenant-content/questions', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
questionId: conflictTarget.id,
|
||||
createVersion: true,
|
||||
content: keepLocalContent,
|
||||
options: acceptedTarget.options || [],
|
||||
correctOptionIndex: acceptedTarget.correctOptionIndex,
|
||||
correctOptionIndices: acceptedTarget.correctOptionIndices || [],
|
||||
answerText: acceptedTarget.answerText,
|
||||
explanation: '租户这一次选择保留本地版本。',
|
||||
sourceHash: `tenant-keep-local-${Date.now()}`,
|
||||
},
|
||||
});
|
||||
|
||||
const platformSecondChangedHash = `platform-source-update-keep-local-${Date.now()}`;
|
||||
await request('/api/tenant-content/questions', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
questionId: ids.question,
|
||||
createVersion: true,
|
||||
content: '平台公共题库二次更新:1 + 2 = ?',
|
||||
options: [
|
||||
{ label: 'A', text: '2' },
|
||||
{ label: 'B', text: '3' },
|
||||
],
|
||||
correctOptionIndex: 1,
|
||||
correctOptionIndices: [1],
|
||||
answerText: '3',
|
||||
explanation: '平台公共题库发布了第二个新版本。',
|
||||
sourceHash: platformSecondChangedHash,
|
||||
},
|
||||
});
|
||||
|
||||
const secondConflictSync = await request('/api/tenant-content/public-question-banks/sync', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: { adoptionId: adopted.item.id, copyLimit: 20 },
|
||||
});
|
||||
assert.equal(secondConflictSync.sync?.status, 'conflict', 'second platform update should conflict with tenant-local edit');
|
||||
|
||||
const keptLocalConflict = await request('/api/tenant-content/public-question-banks/conflicts/resolve', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
adoptionId: adopted.item.id,
|
||||
sourceQuestionId: ids.question,
|
||||
resolution: 'keep_local',
|
||||
},
|
||||
});
|
||||
assert.equal(keptLocalConflict.item?.resolution, 'keep_local', 'tenant admin should keep local version for a conflict');
|
||||
assert.equal(keptLocalConflict.item?.syncStatus, 'synced', 'keeping the only conflict should mark adoption synced');
|
||||
|
||||
const afterKeepLocalSync = await request('/api/tenant-content/public-question-banks/sync', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: { adoptionId: adopted.item.id, copyLimit: 20 },
|
||||
});
|
||||
assert.equal(afterKeepLocalSync.sync?.status, 'synced', 'future sync should not repeat a keep-local conflict for the same platform hash');
|
||||
assert.ok(
|
||||
afterKeepLocalSync.sync?.results?.some(item => item.sourceQuestionId === ids.question && item.action === 'skipped' && item.resolution === 'keep_local'),
|
||||
'future sync should expose keep-local skip decision',
|
||||
);
|
||||
|
||||
const collectionAfterKeepLocal = await request('/api/catalog/question-collections/questions', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: false,
|
||||
query: { collectionId: adopted.item.targetCollectionId, limit: 50 },
|
||||
});
|
||||
const keptLocalTarget = collectionAfterKeepLocal.items?.find(item => item.id === conflictTarget.id);
|
||||
assert.equal(keptLocalTarget?.content, keepLocalContent, 'keep-local resolution should preserve tenant customized content');
|
||||
|
||||
const mainTenantNotAdopted = await request('/api/tenant-content/public-question-banks', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { onlyNotAdopted: 'true' },
|
||||
|
||||
Reference in New Issue
Block a user