forked from wangziqi/gongxue-base
feat: notify tenants about public bank syncs
This commit is contained in:
@@ -49,9 +49,11 @@ import {
|
||||
adoptPublicQuestionBankRoute,
|
||||
publicQuestionBankConflictsRoute,
|
||||
publicQuestionBanksRoute,
|
||||
tenantContentNotificationsRoute,
|
||||
resolvePublicQuestionBankConflictsRoute,
|
||||
resolvePublicQuestionBankConflictRoute,
|
||||
syncPublicQuestionBankRoute,
|
||||
updateTenantContentNotificationStatusRoute,
|
||||
} from './public-banks.js';
|
||||
import {
|
||||
bindQuestionVideoRoute,
|
||||
@@ -82,6 +84,8 @@ import {
|
||||
export const tenantContentRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/tenant-content/content-entries', contentEntriesAdminRoute],
|
||||
['GET', '/api/tenant-content/public-question-banks', publicQuestionBanksRoute],
|
||||
['GET', '/api/tenant-content/notifications', tenantContentNotificationsRoute],
|
||||
['POST', '/api/tenant-content/notifications/status', updateTenantContentNotificationStatusRoute],
|
||||
['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],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
@@ -43,6 +44,24 @@ interface AdoptionRow {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ContentNotificationRow {
|
||||
id: string;
|
||||
notificationType: string;
|
||||
status: string;
|
||||
severity: string;
|
||||
adoptionId: string | null;
|
||||
sourceQuestionBankId: string | null;
|
||||
title: string;
|
||||
message: string;
|
||||
actionLabel: string | null;
|
||||
actionPath: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
readAt: string | null;
|
||||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
interface SourceQuestionSnapshot {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -569,6 +588,131 @@ function syncCounts(results: SyncQuestionResult[]) {
|
||||
};
|
||||
}
|
||||
|
||||
function publicBankNotificationHash(parts: unknown[]) {
|
||||
return crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 40);
|
||||
}
|
||||
|
||||
function publicBankChangedResults(results: SyncQuestionResult[]) {
|
||||
return results
|
||||
.filter(item => item.action === 'inserted' || item.action === 'updated' || item.action === 'conflict')
|
||||
.map(item => ({
|
||||
sourceQuestionId: item.sourceQuestionId,
|
||||
action: item.action,
|
||||
sourceHash: item.sourceHash,
|
||||
previousSourceHash: item.previousSourceHash,
|
||||
targetHash: item.targetHash,
|
||||
}))
|
||||
.sort((left, right) => `${left.sourceQuestionId}:${left.action}`.localeCompare(`${right.sourceQuestionId}:${right.action}`));
|
||||
}
|
||||
|
||||
async function upsertPublicBankSyncNotification(client: pg.PoolClient, input: {
|
||||
auth: PublicQuestionBankSyncAuth;
|
||||
adoption: AdoptionRow;
|
||||
grant: EligibleBankRow;
|
||||
results: SyncQuestionResult[];
|
||||
counts: ReturnType<typeof syncCounts>;
|
||||
conflicts: SyncQuestionResult[];
|
||||
triggeredBy: 'manual' | 'worker';
|
||||
workerId?: string | null;
|
||||
}) {
|
||||
const changedResults = publicBankChangedResults(input.results);
|
||||
if (!changedResults.length) return;
|
||||
|
||||
const hasConflicts = input.conflicts.length > 0;
|
||||
const notificationType = hasConflicts ? 'public_question_bank_conflict' : 'public_question_bank_synced';
|
||||
const severity = hasConflicts ? 'warning' : 'success';
|
||||
const dedupeKey = [
|
||||
'public-bank',
|
||||
input.adoption.id,
|
||||
notificationType,
|
||||
publicBankNotificationHash(changedResults),
|
||||
].join(':');
|
||||
const title = hasConflicts
|
||||
? `${input.grant.sourceQuestionBankName} 有同步冲突`
|
||||
: `${input.grant.sourceQuestionBankName} 已同步`;
|
||||
const message = hasConflicts
|
||||
? `平台公共题库有 ${input.conflicts.length} 道题与租户本地改写冲突,请处理后再发布给学生。`
|
||||
: `平台公共题库已同步:新增 ${input.counts.inserted} 题,更新 ${input.counts.updated} 题。`;
|
||||
const actionPath = hasConflicts
|
||||
? `/tenant-admin/content?adoptionId=${input.adoption.id}&panel=public-bank-conflicts`
|
||||
: `/tenant-admin/content?adoptionId=${input.adoption.id}&panel=public-banks`;
|
||||
const actionLabel = hasConflicts ? '处理冲突' : '查看同步结果';
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.tenant_content_notifications (
|
||||
tenant_id, notification_type, status, severity,
|
||||
adoption_id, source_question_bank_id,
|
||||
title, message, action_label, action_path,
|
||||
dedupe_key, metadata, created_by
|
||||
)
|
||||
values (
|
||||
$1, $2, 'unread', $3,
|
||||
$4, $5,
|
||||
$6, $7, $8, $9,
|
||||
$10, $11::jsonb, $12
|
||||
)
|
||||
on conflict (tenant_id, notification_type, dedupe_key)
|
||||
where dedupe_key is not null
|
||||
do update set severity = excluded.severity,
|
||||
title = excluded.title,
|
||||
message = excluded.message,
|
||||
action_label = excluded.action_label,
|
||||
action_path = excluded.action_path,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
`,
|
||||
[
|
||||
input.auth.tenantId,
|
||||
notificationType,
|
||||
severity,
|
||||
input.adoption.id,
|
||||
input.grant.sourceQuestionBankId,
|
||||
title,
|
||||
message,
|
||||
actionLabel,
|
||||
actionPath,
|
||||
dedupeKey,
|
||||
JSON.stringify({
|
||||
adoptionId: input.adoption.id,
|
||||
sourceQuestionBankId: input.grant.sourceQuestionBankId,
|
||||
sourceQuestionBankName: input.grant.sourceQuestionBankName,
|
||||
targetQuestionBankId: input.adoption.targetQuestionBankId,
|
||||
targetEntryId: input.adoption.targetEntryId,
|
||||
targetCollectionId: input.adoption.targetCollectionId,
|
||||
syncStatus: hasConflicts ? 'conflict' : 'synced',
|
||||
counts: input.counts,
|
||||
changedResults: changedResults.slice(0, 50),
|
||||
triggeredBy: input.triggeredBy,
|
||||
workerId: input.workerId || null,
|
||||
}),
|
||||
input.auth.userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function markPublicBankConflictNotificationsResolved(client: pg.PoolClient, input: {
|
||||
tenantId: string;
|
||||
adoptionId: string;
|
||||
actorUserId: string | null;
|
||||
}) {
|
||||
await client.query(
|
||||
`
|
||||
update public.tenant_content_notifications
|
||||
set status = 'resolved',
|
||||
read_by = coalesce(read_by, $3),
|
||||
read_at = coalesce(read_at, now()),
|
||||
resolved_at = coalesce(resolved_at, now()),
|
||||
updated_at = now()
|
||||
where tenant_id = $1
|
||||
and adoption_id = $2
|
||||
and notification_type = 'public_question_bank_conflict'
|
||||
and status <> 'dismissed'
|
||||
`,
|
||||
[input.tenantId, input.adoptionId, input.actorUserId],
|
||||
);
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
@@ -1072,6 +1216,14 @@ export async function executePublicQuestionBankSync(input: PublicQuestionBankSyn
|
||||
],
|
||||
);
|
||||
|
||||
if (!conflicts.length) {
|
||||
await markPublicBankConflictNotificationsResolved(client, {
|
||||
tenantId: auth.tenantId,
|
||||
adoptionId: adoption.id,
|
||||
actorUserId: auth.userId,
|
||||
});
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
@@ -1096,6 +1248,17 @@ export async function executePublicQuestionBankSync(input: PublicQuestionBankSyn
|
||||
],
|
||||
);
|
||||
|
||||
await upsertPublicBankSyncNotification(client, {
|
||||
auth,
|
||||
adoption: updated.rows[0],
|
||||
grant,
|
||||
results: syncResult.results,
|
||||
counts: syncResult.counts,
|
||||
conflicts,
|
||||
triggeredBy: input.triggeredBy || 'manual',
|
||||
workerId: input.workerId || null,
|
||||
});
|
||||
|
||||
return {
|
||||
item: updated.rows[0],
|
||||
sync: {
|
||||
@@ -1391,6 +1554,14 @@ export async function resolvePublicQuestionBankConflictRoute(ctx: RequestContext
|
||||
],
|
||||
);
|
||||
|
||||
if (!remainingConflicts.length) {
|
||||
await markPublicBankConflictNotificationsResolved(client, {
|
||||
tenantId: auth.tenantId,
|
||||
adoptionId: adoption.id,
|
||||
actorUserId: auth.userId,
|
||||
});
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
@@ -1432,6 +1603,25 @@ function parsePublicQuestionBankConflictResolution(value: string): PublicQuestio
|
||||
throw new HttpError(400, 'resolution must be accept_platform or keep_local', 'PUBLIC_BANK_CONFLICT_RESOLUTION_INVALID');
|
||||
}
|
||||
|
||||
function parseNotificationStatus(value: string) {
|
||||
if (['unread', 'read', 'dismissed', 'resolved'].includes(value)) return value;
|
||||
throw new HttpError(400, 'status must be unread, read, dismissed, or resolved', 'CONTENT_NOTIFICATION_STATUS_INVALID');
|
||||
}
|
||||
|
||||
function parseNotificationUpdateStatus(value: string) {
|
||||
if (['read', 'dismissed', 'resolved'].includes(value)) return value;
|
||||
throw new HttpError(400, 'status must be read, dismissed, or resolved', 'CONTENT_NOTIFICATION_STATUS_INVALID');
|
||||
}
|
||||
|
||||
function uuidString(value: unknown) {
|
||||
const candidate = typeof value === 'string' ? value.trim() : '';
|
||||
if (!candidate) return '';
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate)) {
|
||||
throw new HttpError(400, 'notificationIds must contain UUID values', 'CONTENT_NOTIFICATION_ID_INVALID');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function sourceQuestionIdsFromBody(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
@@ -1747,6 +1937,14 @@ async function resolvePublicQuestionBankConflictsBatch(auth: TenantContentAuth,
|
||||
],
|
||||
);
|
||||
|
||||
if (!remainingConflicts.length) {
|
||||
await markPublicBankConflictNotificationsResolved(client, {
|
||||
tenantId: auth.tenantId,
|
||||
adoptionId: adoption.id,
|
||||
actorUserId: auth.userId,
|
||||
});
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
await client.query(
|
||||
`
|
||||
@@ -1823,3 +2021,107 @@ export async function resolvePublicQuestionBankConflictsRoute(ctx: RequestContex
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function tenantContentNotificationsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const rawStatus = stringParam(ctx, 'status');
|
||||
const status = rawStatus ? parseNotificationStatus(rawStatus) : '';
|
||||
const rawType = stringParam(ctx, 'notificationType');
|
||||
const adoptionId = stringParam(ctx, 'adoptionId');
|
||||
const limit = intParam(ctx, 'limit', 50, 100);
|
||||
|
||||
const items = await query<ContentNotificationRow>(
|
||||
`
|
||||
select id,
|
||||
notification_type as "notificationType",
|
||||
status,
|
||||
severity,
|
||||
adoption_id as "adoptionId",
|
||||
source_question_bank_id as "sourceQuestionBankId",
|
||||
title,
|
||||
message,
|
||||
action_label as "actionLabel",
|
||||
action_path as "actionPath",
|
||||
metadata,
|
||||
created_at as "createdAt",
|
||||
updated_at as "updatedAt",
|
||||
read_at as "readAt",
|
||||
resolved_at as "resolvedAt"
|
||||
from public.tenant_content_notifications
|
||||
where tenant_id = $1
|
||||
and ($2::text = '' or status = $2)
|
||||
and ($3::text = '' or notification_type = $3)
|
||||
and ($4::uuid is null or adoption_id = $4::uuid)
|
||||
order by created_at desc
|
||||
limit $5
|
||||
`,
|
||||
[auth.tenantId, status, rawType, adoptionId || null, limit],
|
||||
);
|
||||
|
||||
const summaryRows = await query<{ status: string; count: number }>(
|
||||
`
|
||||
select status, count(*)::integer as count
|
||||
from public.tenant_content_notifications
|
||||
where tenant_id = $1
|
||||
and ($2::text = '' or notification_type = $2)
|
||||
group by status
|
||||
`,
|
||||
[auth.tenantId, rawType],
|
||||
);
|
||||
const summary = Object.fromEntries(summaryRows.map(row => [row.status, Number(row.count || 0)]));
|
||||
|
||||
return { items, summary };
|
||||
}
|
||||
|
||||
export async function updateTenantContentNotificationStatusRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const status = parseNotificationUpdateStatus(requiredString(body, 'status'));
|
||||
const ids = Array.isArray(body.notificationIds)
|
||||
? body.notificationIds
|
||||
.map(item => uuidString(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, 100)
|
||||
: [];
|
||||
if (!ids.length) {
|
||||
throw new HttpError(400, 'notificationIds is required', 'CONTENT_NOTIFICATION_IDS_REQUIRED');
|
||||
}
|
||||
|
||||
const updated = await query<ContentNotificationRow>(
|
||||
`
|
||||
update public.tenant_content_notifications
|
||||
set status = $3,
|
||||
read_by = case when $3 in ('read', 'dismissed', 'resolved') then coalesce(read_by, $4) else read_by end,
|
||||
read_at = case when $3 in ('read', 'dismissed', 'resolved') then coalesce(read_at, now()) else read_at end,
|
||||
resolved_at = case when $3 = 'resolved' then coalesce(resolved_at, now()) else resolved_at end,
|
||||
updated_at = now()
|
||||
where tenant_id = $1
|
||||
and id = any($2::uuid[])
|
||||
returning id,
|
||||
notification_type as "notificationType",
|
||||
status,
|
||||
severity,
|
||||
adoption_id as "adoptionId",
|
||||
source_question_bank_id as "sourceQuestionBankId",
|
||||
title,
|
||||
message,
|
||||
action_label as "actionLabel",
|
||||
action_path as "actionPath",
|
||||
metadata,
|
||||
created_at as "createdAt",
|
||||
updated_at as "updatedAt",
|
||||
read_at as "readAt",
|
||||
resolved_at as "resolvedAt"
|
||||
`,
|
||||
[auth.tenantId, ids, status, auth.userId],
|
||||
);
|
||||
|
||||
return {
|
||||
item: {
|
||||
status,
|
||||
requestedCount: ids.length,
|
||||
updatedCount: updated.length,
|
||||
notifications: updated,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
loadImportTemplate,
|
||||
loadPublicQuestionBankConflicts,
|
||||
loadPublicQuestionBanks,
|
||||
loadTenantContentNotifications,
|
||||
previewContentImport,
|
||||
resolvePublicQuestionBankConflict,
|
||||
resolvePublicQuestionBankConflicts,
|
||||
runImportPostCheck,
|
||||
syncPublicQuestionBank,
|
||||
updateTenantContentNotificationStatus,
|
||||
type ContentEntryAdminItem,
|
||||
type ImportFieldMapping,
|
||||
type ImportJobDetail,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
type PublicQuestionBankConflictItem,
|
||||
type PublicQuestionBankConflictsResult,
|
||||
type PublicQuestionBankItem,
|
||||
type TenantContentNotificationItem,
|
||||
} from '@/services/tenantAdmin';
|
||||
import '../admin.css';
|
||||
|
||||
@@ -137,6 +140,13 @@ function conflictItems(value: PublicQuestionBankConflictsResult | null) {
|
||||
return Array.isArray(value?.conflicts) ? value.conflicts : [];
|
||||
}
|
||||
|
||||
function notificationStatusText(item: TenantContentNotificationItem) {
|
||||
if (item.status === 'resolved') return '已处理';
|
||||
if (item.status === 'dismissed') return '已忽略';
|
||||
if (item.status === 'read') return '已读';
|
||||
return '未读';
|
||||
}
|
||||
|
||||
function objectRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
@@ -162,6 +172,7 @@ export default function TenantContentPage() {
|
||||
const [entries, setEntries] = useState<ContentEntryAdminItem[]>([]);
|
||||
const [jobs, setJobs] = useState<ImportJobItem[]>([]);
|
||||
const [publicBanks, setPublicBanks] = useState<PublicQuestionBankItem[]>([]);
|
||||
const [notifications, setNotifications] = useState<TenantContentNotificationItem[]>([]);
|
||||
const [selectedJobId, setSelectedJobId] = useState('');
|
||||
const [selectedImportType, setSelectedImportType] = useState<ImportType>('questions');
|
||||
const [sourceFormat, setSourceFormat] = useState<ImportSourceFormat>('json');
|
||||
@@ -187,10 +198,12 @@ export default function TenantContentPage() {
|
||||
loadContentEntriesAdmin().catch(() => ({ items: [] })),
|
||||
loadImportJobs(20).catch(() => ({ items: [] })),
|
||||
loadPublicQuestionBanks().catch(() => ({ items: [] })),
|
||||
]).then(([entryPayload, jobPayload, bankPayload]) => {
|
||||
loadTenantContentNotifications({ limit: 10 }).catch(() => ({ items: [] })),
|
||||
]).then(([entryPayload, jobPayload, bankPayload, notificationPayload]) => {
|
||||
setEntries(entryPayload.items || []);
|
||||
setJobs(jobPayload.items || []);
|
||||
setPublicBanks(bankPayload.items || []);
|
||||
setNotifications(notificationPayload.items || []);
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '内容数据加载失败'));
|
||||
}
|
||||
|
||||
@@ -461,6 +474,8 @@ export default function TenantContentPage() {
|
||||
copyLimit: Math.max(1, Math.min(Number(copyLimit || 1000), 1000)),
|
||||
});
|
||||
setConflicts(payload.sync || null);
|
||||
const notificationPayload = await loadTenantContentNotifications({ limit: 10 });
|
||||
setNotifications(notificationPayload.items || []);
|
||||
Taro.showToast({ title: '同步完成', icon: 'success' });
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
@@ -470,6 +485,21 @@ export default function TenantContentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNotificationStatus(item: TenantContentNotificationItem, status: 'read' | 'dismissed') {
|
||||
if (!item.id) return;
|
||||
setBusy(`notification:${item.id}:${status}`);
|
||||
setError('');
|
||||
try {
|
||||
await updateTenantContentNotificationStatus({ notificationIds: [item.id], status });
|
||||
const payload = await loadTenantContentNotifications({ limit: 10 });
|
||||
setNotifications(payload.items || []);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '通知状态更新失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function showConflicts(item: PublicQuestionBankItem) {
|
||||
const adoptionId = adoptionIdOf(item);
|
||||
if (!adoptionId) {
|
||||
@@ -508,6 +538,8 @@ export default function TenantContentPage() {
|
||||
});
|
||||
const payload = await loadPublicQuestionBankConflicts(adoptionId);
|
||||
setConflicts(payload.item || null);
|
||||
const notificationPayload = await loadTenantContentNotifications({ limit: 10 });
|
||||
setNotifications(notificationPayload.items || []);
|
||||
Taro.showToast({ title: '已处理', icon: 'success' });
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
@@ -545,6 +577,8 @@ export default function TenantContentPage() {
|
||||
});
|
||||
const payload = await loadPublicQuestionBankConflicts(adoptionId);
|
||||
setConflicts(payload.item || null);
|
||||
const notificationPayload = await loadTenantContentNotifications({ limit: 10 });
|
||||
setNotifications(notificationPayload.items || []);
|
||||
Taro.showToast({ title: '批量处理完成', icon: 'success' });
|
||||
reload();
|
||||
} catch (nextError) {
|
||||
@@ -722,6 +756,27 @@ export default function TenantContentPage() {
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>同步通知</Text>
|
||||
<View className='admin-list'>
|
||||
{notifications.map(item => (
|
||||
<View className='admin-row' key={item.id}>
|
||||
<Text className='admin-row-main'>{item.title || '内容通知'} · {notificationStatusText(item)}</Text>
|
||||
<Text className='admin-row-meta'>{item.message || '-'} · {item.createdAt ? String(item.createdAt).slice(0, 16).replace('T', ' ') : ''}</Text>
|
||||
<View className='admin-row-actions'>
|
||||
{item.status === 'unread' ? (
|
||||
<Button className='admin-mini-button primary' loading={busy === `notification:${item.id}:read`} onClick={() => updateNotificationStatus(item, 'read')}>标记已读</Button>
|
||||
) : null}
|
||||
{item.status !== 'dismissed' && item.status !== 'resolved' ? (
|
||||
<Button className='admin-mini-button' loading={busy === `notification:${item.id}:dismissed`} onClick={() => updateNotificationStatus(item, 'dismissed')}>忽略</Button>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{!notifications.length ? <View className='admin-empty'>暂无公共题库同步通知。</View> : null}
|
||||
</View>
|
||||
|
||||
<View className='admin-section'>
|
||||
<Text className='admin-section-title'>公共题库</Text>
|
||||
<View className='admin-list'>
|
||||
|
||||
@@ -218,6 +218,24 @@ export interface PublicQuestionBankConflictsResult {
|
||||
conflicts?: PublicQuestionBankConflictItem[];
|
||||
}
|
||||
|
||||
export interface TenantContentNotificationItem {
|
||||
id: string;
|
||||
notificationType?: string;
|
||||
status?: 'unread' | 'read' | 'dismissed' | 'resolved';
|
||||
severity?: 'info' | 'success' | 'warning' | 'error';
|
||||
adoptionId?: string | null;
|
||||
sourceQuestionBankId?: string | null;
|
||||
title?: string;
|
||||
message?: string;
|
||||
actionLabel?: string | null;
|
||||
actionPath?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
readAt?: string | null;
|
||||
resolvedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ImportIssueItem {
|
||||
id: string;
|
||||
rowNo?: number | null;
|
||||
@@ -651,6 +669,27 @@ export async function loadPublicQuestionBanks() {
|
||||
return apiRequest<{ items?: PublicQuestionBankItem[] }>('/api/tenant-content/public-question-banks');
|
||||
}
|
||||
|
||||
export async function loadTenantContentNotifications(input: {
|
||||
status?: 'unread' | 'read' | 'dismissed' | 'resolved';
|
||||
notificationType?: string;
|
||||
adoptionId?: string;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
return apiRequest<{ items?: TenantContentNotificationItem[]; summary?: Record<string, number> }>('/api/tenant-content/notifications', {
|
||||
query: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTenantContentNotificationStatus(input: {
|
||||
notificationIds: string[];
|
||||
status: 'read' | 'dismissed' | 'resolved';
|
||||
}) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/notifications/status', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
}
|
||||
|
||||
export async function adoptPublicQuestionBank(input: { grantId: string; entryName?: string; collectionName?: string; copyLimit?: number }) {
|
||||
return apiRequest<{ item?: Record<string, unknown> }>('/api/tenant-content/public-question-banks/adopt', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -94,5 +94,5 @@ npm run pb:import:validate
|
||||
|
||||
- `apps/api/src/features` 继续按业务域扩展:退款对账、真实 OAuth provider、平台审计和更多后台任务。
|
||||
- `src/services/supabaseApi.ts` 逐页替换旧 PB 只读接口,优先学生端和小程序共用页面。
|
||||
- 扩展 `apps/worker`:CRM webhook、支付/退款补偿、资源复检、异步导入和公共题库同步已落地;后续继续补日报统计、失败告警、版本通知和冲突处理运营台。
|
||||
- 扩展 `apps/worker`:CRM webhook、支付/退款补偿、资源复检、异步导入、公共题库同步和公共题库同步通知已落地;后续继续补日报统计、失败告警和更完整运营台。
|
||||
- 新增 `apps/taro` 后,Auth/JWT 优先复用 Supabase client;复杂业务命令复用 `apps/api`/RPC/Edge Functions,不单独维护另一套后端逻辑。
|
||||
|
||||
@@ -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`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计 |
|
||||
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新和冲突会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计,冲突全部处理后相关通知自动 resolved |
|
||||
| 题库导出基础 | 可联调 | `/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 支持新增/更新题目、重新校验授权、跨租户拒绝、审计记录和租户自改冲突保护;冲突处理 API 已支持单条/批量采纳平台版本和保留租户本地版本;已覆盖跨租户、重复采纳、采纳后组卷、同步新增题、冲突不覆盖、单条/批量冲突处理和 worker 自动同步测试 |
|
||||
| 公共题库采纳、手动同步和自动同步 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;同步 API 和 `public-banks` worker 支持新增/更新题目、重新校验授权、跨租户拒绝、审计记录、租户内容通知和租户自改冲突保护;冲突处理 API 已支持单条/批量采纳平台版本和保留租户本地版本;已覆盖跨租户、重复采纳、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、冲突不覆盖、单条/批量冲突处理和 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、字段映射覆盖白名单、导入任务详情、异步 worker 状态、导入后复检已接入;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` 已可抢占待同步采纳记录、自动同步平台新增/更新题目、记录失败和审计;同步新增/更新和冲突会写入 `tenant_content_notifications`,租户后台已有通知列表、已读/忽略、单条和批量冲突处理第一版;后续需接入生产定时调度、失败告警和更完整运营消息 |
|
||||
|
||||
## 当前验证
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
| 模块 | 当前状态 | 已经具备 | 上线前还要补 |
|
||||
| --- | --- | --- | --- |
|
||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
|
||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API | 自动计费、平台审计、公共题库版本通知和运营消息 |
|
||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 |
|
||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、JSON/试卷 payload 导出 | 长题干/公式图片混排体验、PDF/Word 导出 worker、公共题库版本通知、排行榜防刷/预聚合 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出 | 长题干/公式图片混排体验、PDF/Word 导出 worker、排行榜防刷/预聚合 |
|
||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入 | 复杂筛选、AI 择校上下文 |
|
||||
@@ -86,7 +86,7 @@
|
||||
- 完整资金流水对账、账单下载比对和异常订单运营台。
|
||||
- XPay 或其它实际支付网关 adapter。
|
||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。
|
||||
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有单条/批量冲突采纳平台或保留本地操作;继续补版本通知,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
|
||||
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;继续补生产定时调度、失败告警,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
|
||||
- 导入模板、字段映射、导入任务详情和复检 API 已可用;Taro 租户内容页已接模板下载、字段别名覆盖、导入执行、异步 job 轮询和复检结果面板第一版。前端继续补真实导入目标选择体验和大数据量导入验收。
|
||||
- 视频深度防盗链、动态水印和播放统计。
|
||||
- 数据看板 API:收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。
|
||||
|
||||
@@ -261,7 +261,7 @@ GET /api/tenant-admin/audit-logs
|
||||
2. 完成真实短信 provider 联调:阿里云/腾讯云,密钥放 `app_private.tenant_secrets` 或生产 Vault。
|
||||
3. 完成真实 OAuth provider 联调:微信网页、微信小程序、QQ,确认回调域名、开放平台账号和旧 PocketBase 身份映射策略。
|
||||
4. 补完整资金流水对账、异常订单运营台和优惠券核销报表;支付/退款补偿、退款查询确认和退款通知主链路已完成。
|
||||
5. 扩展 `apps/worker`:日报统计、CRM 死信告警、公共题库版本通知和冲突处理运营台;公共题库同步 worker 已具备单批执行能力。
|
||||
5. 扩展 `apps/worker`:日报统计、CRM 死信告警、公共题库同步失败告警和更完整冲突处理运营台;公共题库同步 worker 和同步通知已具备基础闭环。
|
||||
6. 开始 Taro scaffold,把 `supabaseApi` 抽到跨端包或适配层。
|
||||
|
||||
## 测试命令
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
| 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略、地区/全国套餐权限、平台侧主题模板库、平台审计 |
|
||||
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
|
||||
| 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 |
|
||||
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker 和冲突查询 API | 字段映射 UI、公共题库版本通知/冲突操作台、可视化拖拽排序前端 |
|
||||
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知和冲突查询 API | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |
|
||||
| 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹、模考交卷评分报告、错题复习计划、排行榜 | 专项练习策略、题型统计深度分析、排行榜防刷/预聚合 |
|
||||
| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、每日复习计划、旧模板/新模板 JSON/CSV/Excel 预览导入、内容导航绑定、排行榜 | 更细复习参数 |
|
||||
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 |
|
||||
@@ -37,7 +37,7 @@
|
||||
## 接下来优先级
|
||||
|
||||
1. 完善内容导入和对象存储:字段映射 UI、真实数据 dry-run、CDN 防盗链、杀毒扫描和视频水印。
|
||||
2. 公共题库/地区题库授权:已完成披露、采纳快照、手动同步、自动同步 worker、冲突查询和租户自改保护;继续补版本通知、冲突操作台和按 SaaS 套餐限制地区。
|
||||
2. 公共题库/地区题库授权:已完成披露、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和租户自改保护;继续补生产失败告警、冲突操作台增强和按 SaaS 套餐限制地区。
|
||||
3. 学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
||||
4. 视频会员控制:深度防盗链、水印和播放统计。
|
||||
5. 数据看板预聚合:把实时聚合升级为大租户可承载的日/周/月预聚合。
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
## 当前可进入的前端工作
|
||||
|
||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款第一版;租户设置页已具备角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款第一版;租户设置页已具备角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:长题干、公式图片混排、勋章展示和小程序端分享/支付体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频解析、题目反馈、模考/练习报告、错题复习、收藏复习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||
- 可以按新后端主模型接入内容导航:
|
||||
@@ -87,11 +87,11 @@
|
||||
| 工作台 | `apps/taro/src/pages/tenant-admin/workbench/index.tsx` | `tenant-admin/overview`、`tenant-admin/dashboard`、`tenant-admin/permissions` |
|
||||
| 数据看板 | `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/teachers`、`tenant-admin/students`、`students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups` |
|
||||
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`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`、`public-question-banks/conflicts/resolve-batch` |
|
||||
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`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`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`、`tenant-content/notifications/status` |
|
||||
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/config`、`crm/queue`、`commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status` |
|
||||
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
||||
|
||||
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进;题库内容页支持公共题库采纳/同步、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款;租户设置页支持角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、真实打款 provider、结算导出和凭证。
|
||||
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款;租户设置页支持角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、真实打款 provider、结算导出和凭证。
|
||||
|
||||
## 已落地的 Taro 平台后台页面
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 多租户隔离 | 已建 `tenants`、`tenant_domains`、`tenant_branding`、`tenant_settings`、RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环未完成 |
|
||||
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合,阅读理解/案例分析子题沿用 `subQuestions/sub_questions` | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、复合题 `subAnswers` 判分和报告明细、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker、冲突查询和单条/批量冲突处理已实现 | 核心 API 集成测试含导航、组卷、复合题后台录入/练习/判分/报告、导入、导出权限/脱敏、公共题库授权、采纳后组卷、同步新增题、租户自改冲突保护、单条/批量冲突处理和 worker 自动同步断言 | 新题库导航和组卷基础闭环可跑,阅读理解/案例分析多小题第一版可联调,公共题库采纳/手动/自动同步、冲突查询/处理、导入后复检、模板下载、字段映射 API 和导出基础可联调;PDF/Word 导出 worker、公共题库版本通知和运营消息仍需补齐 |
|
||||
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、导出任务台账、公共题库授权/采纳表、租户内容通知表 | 已支持核心映射,JSON/CSV/Excel 导入可落到新入口/节点/集合,阅读理解/案例分析子题沿用 `subQuestions/sub_questions` | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、复合题 `subAnswers` 判分和报告明细、租户后台题目录入/更新、JSON/CSV/Excel 预览/导入、JSON/试卷 payload 导出、异步导入 worker、平台公共题库授权、租户采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和单条/批量冲突处理已实现 | 核心 API 集成测试含导航、组卷、复合题后台录入/练习/判分/报告、导入、导出权限/脱敏、公共题库授权、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、租户自改冲突保护、单条/批量冲突处理和 worker 自动同步断言 | 新题库导航和组卷基础闭环可跑,阅读理解/案例分析多小题第一版可联调,公共题库采纳/手动/自动同步、同步通知、冲突查询/处理、导入后复检、模板下载、字段映射 API 和导出基础可联调;PDF/Word 导出 worker、公共题库生产调度/失败告警和更完整运营消息仍需补齐 |
|
||||
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
|
||||
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
|
||||
| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,对账、支付补偿和异常订单自动处理待补 |
|
||||
@@ -287,7 +287,7 @@ platform-admin:
|
||||
为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续:
|
||||
|
||||
1. 完善内容导入和文件上传:字段映射 UI、真实数据 dry-run 执行验收、CDN 防盗链、杀毒扫描。
|
||||
2. 补公共题库版本通知/运营消息、租户套餐地区/科目/题库范围限制、主题模板系统。
|
||||
2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。
|
||||
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
||||
4. 补视频商用控制:深度防盗链、动态水印和播放统计。
|
||||
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
| SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 |
|
||||
| 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 |
|
||||
| 租户用量记录 | 已覆盖 | 自动采集 worker 待补 |
|
||||
| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、冲突查询 API、单条/批量冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺版本通知和更完整运营消息 |
|
||||
| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权编辑、租户可采纳列表、采纳快照复制、采纳后练习组卷、手动同步 API、自动同步 worker、同步通知、冲突查询 API、单条/批量冲突“采纳平台/保留本地”处理和平台后台页面;同步会重新校验授权、复制平台新增/更新题目,并对租户自改题目返回冲突不覆盖 | 缺生产定时调度、失败告警和更完整运营消息 |
|
||||
| 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 |
|
||||
| 租户安全审计 | 部分覆盖 | audit logs 有;缺平台级审计报表 |
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
3. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。
|
||||
4. 题库导出:服务端 JSON/试卷 payload 导出、权限审计和答案脱敏已补;仍缺 PDF/Word 二进制生成、水印、资料发布和后台导出操作台。
|
||||
5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
|
||||
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需版本通知和运营后台消息。
|
||||
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
|
||||
7. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。
|
||||
8. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
|
||||
9. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。
|
||||
@@ -120,7 +120,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 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
|
||||
- 租户后台数据看板已完成首版聚合 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 和单条/批量冲突处理 API。
|
||||
- 已完成 `public-banks` worker 自动同步、失败记录、审计、同步通知、冲突查询 API 和单条/批量冲突处理 API。
|
||||
- 继续补按 SaaS 套餐限制地区数量、科目范围、题库范围的更细计费策略。
|
||||
- 继续补生产定时调度、版本通知和运营后台消息。
|
||||
- 继续补生产定时调度、失败告警和更完整运营后台消息。
|
||||
|
||||
6. 视频会员控制
|
||||
- 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。
|
||||
@@ -219,5 +219,5 @@
|
||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
|
||||
6. 并行补对象存储、真实登录、完整资金流水对账、题库导出 PDF/Word worker 和公共题库版本通知。
|
||||
6. 并行补对象存储、真实登录、完整资金流水对账、题库导出 PDF/Word worker、公共题库生产定时调度和失败告警。
|
||||
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
|
||||
@@ -1149,6 +1149,8 @@ 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/public-question-banks/conflicts/resolve-batch
|
||||
GET /api/tenant-content/notifications
|
||||
POST /api/tenant-content/notifications/status
|
||||
```
|
||||
|
||||
采纳请求:
|
||||
@@ -1221,9 +1223,12 @@ POST /api/tenant-content/public-question-banks/conflicts/resolve-batch
|
||||
- 页面初始化或 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 后续同步不再反复提示。两种操作都会写审计日志。
|
||||
- 批量冲突处理调用 `POST /api/tenant-content/public-question-banks/conflicts/resolve-batch`,body 为 `{ "adoptionId": "...", "sourceQuestionIds": ["..."], "resolution": "accept_platform | keep_local", "limit": 50 }`。后端最多处理 100 条,仍会重新校验租户授权、锁定采纳记录和目标题,逐条写审计;前端只提交当前冲突列表中明确展示给操作者的 source id。
|
||||
- 同步产生新增/更新时,后端会写入 `public_question_bank_synced` 通知;同步产生冲突时,会写入 `public_question_bank_conflict` 通知。通知只包含同步摘要、题库 ID、题目 hash 和操作入口,不保存题目答案或解析。
|
||||
- 租户后台可调用 `GET /api/tenant-content/notifications?notificationType=public_question_bank_conflict&status=unread&limit=20` 展示待处理同步消息;也可带 `adoptionId` 查看某个采纳记录的通知。
|
||||
- 通知状态更新调用 `POST /api/tenant-content/notifications/status`,body 为 `{ "notificationIds": ["..."], "status": "read | dismissed | resolved" }`。冲突被单条或批量全部处理后,后端会自动把相关冲突通知标记为 `resolved`。
|
||||
- `QUESTION_BANK_GRANT_NOT_AVAILABLE`:说明 SaaS 套餐/授权已失效,提示联系平台或升级套餐。
|
||||
- `QUESTION_BANK_ADOPTION_NOT_FOUND`:说明不是当前租户的采纳记录或记录已归档,前端不要跨租户重试。
|
||||
- 后续会补版本通知和更完整运营消息;当前租户后台可以先提供手动“同步平台更新”按钮,并展示 worker 自动同步后的冲突查询结果、单条处理和批量处理按钮。
|
||||
- 当前租户后台可以提供手动“同步平台更新”按钮,并展示 worker 自动同步后的通知、冲突查询结果、单条处理和批量处理按钮。后续继续补更完整运营消息、失败告警和生产定时调度。
|
||||
|
||||
## 登录对接
|
||||
|
||||
|
||||
@@ -4238,6 +4238,46 @@ async function testPublicQuestionBankAdoption() {
|
||||
'public bank sync should insert newly published source questions',
|
||||
);
|
||||
|
||||
const syncNotifications = await request('/api/tenant-content/notifications', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
query: { notificationType: 'public_question_bank_synced', adoptionId: adopted.item.id },
|
||||
});
|
||||
const syncNotification = syncNotifications.items?.find(item => item.adoptionId === adopted.item.id && item.status === 'unread');
|
||||
assert.ok(syncNotification?.id, 'public bank sync with inserted questions should create an unread tenant notification');
|
||||
assert.equal(syncNotification?.severity, 'success', 'successful public bank sync notification should be success severity');
|
||||
assert.equal(syncNotifications.summary?.unread >= 1, true, 'notification summary should include unread public bank sync notification');
|
||||
assert.ok(!JSON.stringify(syncNotification).includes('新增公共题应同步到已采纳租户'), 'public bank notification should not leak question explanations');
|
||||
|
||||
const markedSyncNotification = await request('/api/tenant-content/notifications/status', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: { notificationIds: [syncNotification.id], status: 'read' },
|
||||
});
|
||||
assert.equal(markedSyncNotification.item?.updatedCount, 1, 'tenant admin should mark content notification as read');
|
||||
const invalidNotificationId = await request('/api/tenant-content/notifications/status', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: { notificationIds: ['not-a-uuid'], status: 'read' },
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(invalidNotificationId.code, 'CONTENT_NOTIFICATION_ID_INVALID', 'notification status API should reject malformed UUIDs before querying');
|
||||
|
||||
const crossTenantNotifications = await request('/api/tenant-content/notifications', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { adoptionId: adopted.item.id },
|
||||
});
|
||||
assert.equal(crossTenantNotifications.items?.length, 0, 'content notifications must not expose partner tenant rows to another tenant admin');
|
||||
const crossTenantNotificationsDenied = await request('/api/tenant-content/notifications', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { adoptionId: adopted.item.id },
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(crossTenantNotificationsDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'content notifications must require tenant content permission in the current tenant');
|
||||
|
||||
const collectionAfterSync = await request('/api/catalog/question-collections/questions', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: false,
|
||||
@@ -4302,6 +4342,16 @@ async function testPublicQuestionBankAdoption() {
|
||||
'public bank sync should identify the source question that conflicts with tenant edits',
|
||||
);
|
||||
|
||||
const conflictNotifications = await request('/api/tenant-content/notifications', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
query: { notificationType: 'public_question_bank_conflict', adoptionId: adopted.item.id },
|
||||
});
|
||||
const conflictNotification = conflictNotifications.items?.find(item => item.adoptionId === adopted.item.id && item.status === 'unread');
|
||||
assert.ok(conflictNotification?.id, 'public bank conflicts should create an unread tenant notification');
|
||||
assert.equal(conflictNotification?.severity, 'warning', 'public bank conflict notification should be warning severity');
|
||||
assert.equal(conflictNotification?.metadata?.syncStatus, 'conflict', 'public bank conflict notification should expose conflict status metadata');
|
||||
|
||||
const conflictList = await request('/api/tenant-content/public-question-banks/conflicts', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
@@ -4369,6 +4419,15 @@ async function testPublicQuestionBankAdoption() {
|
||||
query: { adoptionId: adopted.item.id },
|
||||
});
|
||||
assert.equal(conflictListAfterAccept.item?.conflictCount, 0, 'resolved conflict list should have no remaining conflicts');
|
||||
const conflictNotificationsAfterAccept = await request('/api/tenant-content/notifications', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
query: { notificationType: 'public_question_bank_conflict', adoptionId: adopted.item.id, status: 'resolved' },
|
||||
});
|
||||
assert.ok(
|
||||
conflictNotificationsAfterAccept.items?.some(item => item.id === conflictNotification.id && item.status === 'resolved'),
|
||||
'resolving all public bank conflicts should mark related notifications resolved',
|
||||
);
|
||||
|
||||
const keepLocalContent = `租户再次保留本地公共题副本 ${Date.now()}`;
|
||||
await request('/api/tenant-content/questions', {
|
||||
|
||||
@@ -207,6 +207,26 @@ async function main() {
|
||||
assert.equal(audit.rows[0]?.action, 'content.public_question_bank.synced', 'worker should write sync audit');
|
||||
assert.equal(audit.rows[0]?.details?.triggeredBy, 'worker', 'worker audit should record trigger source');
|
||||
|
||||
const notifications = await pool.query(
|
||||
`
|
||||
select notification_type, status, severity, metadata
|
||||
from public.tenant_content_notifications
|
||||
where tenant_id = $1 and adoption_id = $2
|
||||
order by created_at desc
|
||||
limit 5
|
||||
`,
|
||||
[PARTNER_TENANT_ID, ids.adoption],
|
||||
);
|
||||
assert.ok(
|
||||
notifications.rows.some(row => row.notification_type === 'public_question_bank_synced' && row.status === 'unread' && row.severity === 'success'),
|
||||
'worker public bank sync should create an unread success notification',
|
||||
);
|
||||
assert.equal(
|
||||
notifications.rows.find(row => row.notification_type === 'public_question_bank_synced')?.metadata?.triggeredBy,
|
||||
'worker',
|
||||
'worker public bank sync notification should record trigger source',
|
||||
);
|
||||
|
||||
const secondOutput = await runWorkerOnce();
|
||||
assert.match(secondOutput, /processed=0/, 'worker should skip already synced public bank when source has not changed');
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
create table if not exists public.tenant_content_notifications (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
notification_type text not null
|
||||
check (notification_type in (
|
||||
'public_question_bank_synced',
|
||||
'public_question_bank_conflict'
|
||||
)),
|
||||
status text not null default 'unread'
|
||||
check (status in ('unread', 'read', 'dismissed', 'resolved')),
|
||||
severity text not null default 'info'
|
||||
check (severity in ('info', 'success', 'warning', 'error')),
|
||||
adoption_id uuid references public.tenant_question_bank_adoptions(id) on delete cascade,
|
||||
source_question_bank_id uuid references public.question_banks(id) on delete cascade,
|
||||
title text not null,
|
||||
message text not null,
|
||||
action_label text,
|
||||
action_path text,
|
||||
dedupe_key text,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_by uuid references public.platform_users(id) on delete set null,
|
||||
read_by uuid references public.platform_users(id) on delete set null,
|
||||
read_at timestamptz,
|
||||
resolved_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create unique index if not exists idx_tenant_content_notifications_dedupe
|
||||
on public.tenant_content_notifications(tenant_id, notification_type, dedupe_key)
|
||||
where dedupe_key is not null;
|
||||
|
||||
create index if not exists idx_tenant_content_notifications_tenant_status
|
||||
on public.tenant_content_notifications(tenant_id, status, created_at desc);
|
||||
|
||||
create index if not exists idx_tenant_content_notifications_adoption
|
||||
on public.tenant_content_notifications(tenant_id, adoption_id, notification_type, status);
|
||||
|
||||
alter table public.tenant_content_notifications enable row level security;
|
||||
|
||||
drop policy if exists tenant_content_notifications_isolation on public.tenant_content_notifications;
|
||||
create policy tenant_content_notifications_isolation on public.tenant_content_notifications
|
||||
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.tenant_content_notifications;
|
||||
create trigger set_updated_at
|
||||
before update on public.tenant_content_notifications
|
||||
for each row execute function app.touch_updated_at();
|
||||
Reference in New Issue
Block a user