feat: add feedback operations report

This commit is contained in:
Codex
2026-06-30 15:38:47 +08:00
parent 87f4cf9c4e
commit ddad2ac04d
17 changed files with 593 additions and 44 deletions

View File

@@ -675,7 +675,10 @@ function sanitizeOAuthProfilePayload(value: unknown): unknown {
normalized.includes('refreshtoken') || normalized.includes('refreshtoken') ||
normalized.includes('sessionkey') || normalized.includes('sessionkey') ||
normalized.includes('secret') || normalized.includes('secret') ||
normalized.includes('clientsecret') normalized.includes('clientsecret') ||
normalized.includes('avatar') ||
normalized.includes('headimg') ||
normalized.includes('figureurl')
) { ) {
continue; continue;
} }
@@ -764,16 +767,13 @@ async function callWechatWebUserInfo(input: {
} }
const nickname = stringField(raw, ['nickname', 'name']); const nickname = stringField(raw, ['nickname', 'name']);
const avatarUrl = stringField(raw, ['headimgurl', 'avatarUrl', 'avatar_url']);
const unionId = stringField(raw, ['unionid', 'unionId']); const unionId = stringField(raw, ['unionid', 'unionId']);
return { return {
nickname, nickname,
avatarUrl,
unionId, unionId,
profile: { profile: {
...jsonObject(sanitizeOAuthProfilePayload(raw)), ...jsonObject(sanitizeOAuthProfilePayload(raw)),
nickname, nickname,
avatarUrl,
}, },
}; };
} }
@@ -934,7 +934,6 @@ export async function wechatWebLoginRoute(ctx: RequestContext) {
...userInfo.profile, ...userInfo.profile,
...jsonObject(sanitizeOAuthProfilePayload(clientProfile)), ...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']), nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']),
}; };
return transaction(async client => { return transaction(async client => {
@@ -1069,14 +1068,11 @@ async function callQqUserInfo(input: {
} }
const nickname = stringField(raw, ['nickname', 'name']); const nickname = stringField(raw, ['nickname', 'name']);
const avatarUrl = stringField(raw, ['figureurl_qq_2', 'figureurl_qq_1', 'figureurl_2', 'figureurl_1', 'avatarUrl']);
return { return {
nickname, nickname,
avatarUrl,
profile: { profile: {
...jsonObject(sanitizeOAuthProfilePayload(raw)), ...jsonObject(sanitizeOAuthProfilePayload(raw)),
nickname, nickname,
avatarUrl,
}, },
}; };
} }
@@ -1135,7 +1131,6 @@ export async function qqLoginRoute(ctx: RequestContext) {
...userInfo.profile, ...userInfo.profile,
...jsonObject(sanitizeOAuthProfilePayload(clientProfile)), ...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']), nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']),
}; };
return transaction(async client => { return transaction(async client => {

View File

@@ -56,6 +56,36 @@ export function hashSmsCode(tenantId: string, phone: string, purpose: string, co
.digest('hex'); .digest('hex');
} }
function stripUnsupportedOAuthProfileFields(value: unknown): unknown {
if (Array.isArray(value)) return value.map(item => stripUnsupportedOAuthProfileFields(item));
if (!value || typeof value !== 'object') return value;
const sanitized: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
const normalized = key.toLowerCase().replace(/[-_\s]/g, '');
if (
normalized.includes('accesstoken') ||
normalized.includes('refreshtoken') ||
normalized.includes('sessionkey') ||
normalized.includes('secret') ||
normalized.includes('clientsecret') ||
normalized.includes('avatar') ||
normalized.includes('headimg') ||
normalized.includes('figureurl')
) {
continue;
}
sanitized[key] = stripUnsupportedOAuthProfileFields(child);
}
return sanitized;
}
function publicOAuthProfile(profile: Record<string, unknown> | undefined) {
const sanitized = stripUnsupportedOAuthProfileFields(profile || {});
return sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized)
? sanitized as Record<string, unknown>
: {};
}
export function createSessionToken() { export function createSessionToken() {
return `tk_${crypto.randomBytes(32).toString('base64url')}`; return `tk_${crypto.randomBytes(32).toString('base64url')}`;
} }
@@ -300,7 +330,7 @@ export async function upsertOAuthUser(
return { user: matchedUser, isNewUser: false }; return { user: matchedUser, isNewUser: false };
} }
const profile = input.profile || {}; const profile = publicOAuthProfile(input.profile);
const nickname = const nickname =
typeof profile.nickname === 'string' typeof profile.nickname === 'string'
? profile.nickname ? profile.nickname
@@ -309,17 +339,11 @@ export async function upsertOAuthUser(
: typeof profile.name === 'string' : typeof profile.name === 'string'
? profile.name ? profile.name
: null; : null;
const avatarUrl =
typeof profile.avatarUrl === 'string'
? profile.avatarUrl
: typeof profile.avatar_url === 'string'
? profile.avatar_url
: null;
const userResult = await client.query<PlatformUserSummary>( const userResult = await client.query<PlatformUserSummary>(
` `
insert into public.platform_users (username, phone, email, name, avatar_url, primary_role, raw_profile) insert into public.platform_users (username, phone, email, name, primary_role, raw_profile)
values ($1, $2, $3::citext, $4, $5, 'student', $6::jsonb) values ($1, $2, $3::citext, $4, 'student', $5::jsonb)
returning id, username, phone, name, avatar_url as "avatarUrl", returning id, username, phone, name, avatar_url as "avatarUrl",
primary_role as "primaryRole", created_at as "createdAt" primary_role as "primaryRole", created_at as "createdAt"
`, `,
@@ -328,7 +352,6 @@ export async function upsertOAuthUser(
input.phone || null, input.phone || null,
input.email || null, input.email || null,
nickname, nickname,
avatarUrl,
JSON.stringify({ JSON.stringify({
source: input.provider, source: input.provider,
profile, profile,

View File

@@ -22,6 +22,7 @@ import { tenantUserNotificationsRoute } from './notifications.js';
import { import {
tenantExamDatesRoute, tenantExamDatesRoute,
tenantFeedbackEventsRoute, tenantFeedbackEventsRoute,
tenantFeedbackReportRoute,
tenantFeedbacksRoute, tenantFeedbacksRoute,
updateTenantFeedbackStatusRoute, updateTenantFeedbackStatusRoute,
upsertTenantExamDateRoute, upsertTenantExamDateRoute,
@@ -131,6 +132,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [
['GET', '/api/tenant-admin/exam-dates', tenantExamDatesRoute], ['GET', '/api/tenant-admin/exam-dates', tenantExamDatesRoute],
['PUT', '/api/tenant-admin/exam-dates', upsertTenantExamDateRoute], ['PUT', '/api/tenant-admin/exam-dates', upsertTenantExamDateRoute],
['GET', '/api/tenant-admin/feedbacks', tenantFeedbacksRoute], ['GET', '/api/tenant-admin/feedbacks', tenantFeedbacksRoute],
['GET', '/api/tenant-admin/feedbacks/report', tenantFeedbackReportRoute],
['POST', '/api/tenant-admin/feedbacks/status', updateTenantFeedbackStatusRoute], ['POST', '/api/tenant-admin/feedbacks/status', updateTenantFeedbackStatusRoute],
['GET', '/api/tenant-admin/feedbacks/events', tenantFeedbackEventsRoute], ['GET', '/api/tenant-admin/feedbacks/events', tenantFeedbackEventsRoute],
['GET', '/api/tenant-admin/code-batches', codeBatchesRoute], ['GET', '/api/tenant-admin/code-batches', codeBatchesRoute],

View File

@@ -14,6 +14,11 @@ type JsonBody = Record<string, unknown>;
const REPORT_STATUSES = ['pending', 'accepted', 'rejected', 'resolved', 'closed']; const REPORT_STATUSES = ['pending', 'accepted', 'rejected', 'resolved', 'closed'];
const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent']; const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
const FEEDBACK_REPORT_RANGES: Record<string, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
};
const REPORT_STATUS_LABELS: Record<string, string> = { const REPORT_STATUS_LABELS: Record<string, string> = {
pending: '待处理', pending: '待处理',
accepted: '已受理', accepted: '已受理',
@@ -35,10 +40,71 @@ function intValue(value: unknown, fallback: number) {
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback; return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
} }
function numberValue(value: unknown, fallback = 0) {
const numberValue = Number(value ?? fallback);
return Number.isFinite(numberValue) ? numberValue : fallback;
}
function boolValue(value: unknown, fallback: boolean) { function boolValue(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback; return typeof value === 'boolean' ? value : fallback;
} }
function boundedIntParam(ctx: RequestContext, name: string, fallback: number, min: number, max: number) {
const parsed = Number(ctx.url.searchParams.get(name) || fallback);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, Math.trunc(parsed)));
}
function shanghaiDateParts(date = new Date()) {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return Object.fromEntries(formatter.formatToParts(date).map(part => [part.type, part.value])) as {
year: string;
month: string;
day: string;
};
}
function shanghaiDateKey(date = new Date()) {
const parts = shanghaiDateParts(date);
return `${parts.year}-${parts.month}-${parts.day}`;
}
function addDaysKey(dateKey: string, days: number) {
const date = new Date(`${dateKey}T00:00:00+08:00`);
date.setUTCDate(date.getUTCDate() + days);
return shanghaiDateKey(date);
}
function shanghaiDayStartIso(dateKey: string) {
return new Date(`${dateKey}T00:00:00+08:00`).toISOString();
}
function feedbackReportRange(value: string) {
const timeRange = value || '30d';
const days = FEEDBACK_REPORT_RANGES[timeRange];
if (!days) throw new HttpError(400, 'Unsupported feedback report range', 'INVALID_FEEDBACK_REPORT_RANGE');
const endDate = shanghaiDateKey();
const startDate = addDaysKey(endDate, -(days - 1));
return {
timeRange,
days,
startDate,
endDate,
startAt: shanghaiDayStartIso(startDate),
endAt: shanghaiDayStartIso(addDaysKey(endDate, 1)),
};
}
function ratio(numerator: number, denominator: number) {
if (!denominator) return 0;
return Number((numerator / denominator).toFixed(4));
}
function feedbackNotificationSeverity(status: string) { function feedbackNotificationSeverity(status: string) {
if (status === 'resolved') return 'success'; if (status === 'resolved') return 'success';
if (status === 'rejected') return 'warning'; if (status === 'rejected') return 'warning';
@@ -246,6 +312,307 @@ export async function tenantFeedbacksRoute(ctx: RequestContext) {
return { items }; return { items };
} }
export async function tenantFeedbackReportRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'feedback:read');
const range = feedbackReportRange(stringParam(ctx, 'timeRange'));
const limit = boundedIntParam(ctx, 'limit', 20, 1, 100);
const baseParams = [auth.tenantId, range.startAt, range.endAt];
const [
summary,
byStatus,
byType,
byCategory,
byPriority,
dailyTrend,
topQuestions,
topHandlers,
recentUnhandled,
] = await Promise.all([
query<Record<string, unknown>>(
`
with created_reports as (
select *
from public.reports
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
),
handled_reports as (
select *
from public.reports
where tenant_id = $1
and handled_at >= $2::timestamptz
and handled_at < $3::timestamptz
and status <> 'pending'
),
feedback_rewards as (
select e.*
from public.user_score_events e
join public.reports r on r.tenant_id = e.tenant_id and r.id = e.source_id
where e.tenant_id = $1
and e.created_at >= $2::timestamptz
and e.created_at < $3::timestamptz
and e.event_type = 'feedback_reward'
and e.source_type = 'reports'
)
select
(select count(*)::int from created_reports) as total,
(select count(*)::int from created_reports where status = 'pending') as pending,
(select count(*)::int from created_reports where status = 'accepted') as accepted,
(select count(*)::int from created_reports where status = 'rejected') as rejected,
(select count(*)::int from created_reports where status = 'resolved') as resolved,
(select count(*)::int from created_reports where status = 'closed') as closed,
(select count(*)::int from created_reports where priority in ('high', 'urgent')) as "highPriority",
(select count(*)::int from public.reports where tenant_id = $1 and status in ('pending', 'accepted')) as "pendingBacklog",
(select count(*)::int from handled_reports) as "handledInRange",
(select count(distinct source_id)::int from feedback_rewards where source_id is not null) as "rewardedReports",
(select coalesce(sum(points), 0)::int from feedback_rewards where points > 0) as "rewardPoints",
(
select coalesce(avg(extract(epoch from (handled_at - created_at)) / 3600), 0)::numeric
from handled_reports
where handled_at is not null
and handled_at >= created_at
) as "avgHandleHours"
`,
baseParams,
),
query<Record<string, unknown>>(
`
select status,
case status
when 'pending' then '待处理'
when 'accepted' then '已受理'
when 'rejected' then '未采纳'
when 'resolved' then '已解决'
when 'closed' then '已关闭'
else status
end as label,
count(*)::int as count,
count(*) filter (where priority in ('high', 'urgent'))::int as "highPriority"
from public.reports
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
group by status
order by case status
when 'pending' then 1
when 'accepted' then 2
when 'resolved' then 3
when 'rejected' then 4
when 'closed' then 5
else 9
end
`,
baseParams,
),
query<Record<string, unknown>>(
`
select coalesce(type, 'other') as type,
count(*)::int as count,
max(created_at) as "latestAt"
from public.reports
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
group by coalesce(type, 'other')
order by count desc, "latestAt" desc
`,
baseParams,
),
query<Record<string, unknown>>(
`
select coalesce(nullif(category, ''), 'uncategorized') as category,
count(*)::int as count,
max(created_at) as "latestAt"
from public.reports
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
group by coalesce(nullif(category, ''), 'uncategorized')
order by count desc, "latestAt" desc
limit $4
`,
[...baseParams, limit],
),
query<Record<string, unknown>>(
`
select priority,
count(*)::int as count
from public.reports
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
group by priority
order by case priority
when 'urgent' then 1
when 'high' then 2
when 'normal' then 3
when 'low' then 4
else 9
end
`,
baseParams,
),
query<Record<string, unknown>>(
`
with days as (
select generate_series(
$2::timestamptz,
$3::timestamptz - interval '1 day',
interval '1 day'
) as day_start
),
created_daily as (
select (created_at at time zone 'Asia/Shanghai')::date::text as date,
count(*)::int as total,
count(*) filter (where status = 'pending')::int as pending,
count(*) filter (where status = 'resolved')::int as resolved,
count(*) filter (where priority in ('high', 'urgent'))::int as "highPriority"
from public.reports
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
group by (created_at at time zone 'Asia/Shanghai')::date
),
handled_daily as (
select (handled_at at time zone 'Asia/Shanghai')::date::text as date,
count(*)::int as handled
from public.reports
where tenant_id = $1
and handled_at >= $2::timestamptz
and handled_at < $3::timestamptz
and status <> 'pending'
group by (handled_at at time zone 'Asia/Shanghai')::date
)
select (d.day_start at time zone 'Asia/Shanghai')::date::text as date,
coalesce(cd.total, 0)::int as total,
coalesce(cd.pending, 0)::int as pending,
coalesce(cd.resolved, 0)::int as resolved,
coalesce(cd."highPriority", 0)::int as "highPriority",
coalesce(hd.handled, 0)::int as handled
from days d
left join created_daily cd on cd.date = (d.day_start at time zone 'Asia/Shanghai')::date::text
left join handled_daily hd on hd.date = (d.day_start at time zone 'Asia/Shanghai')::date::text
order by date asc
`,
baseParams,
),
query<Record<string, unknown>>(
`
select r.question_id as "questionId",
q.type as "questionType",
left(coalesce(nullif(q.type_label, ''), nullif(v.content, ''), q.legacy_id, r.question_id::text), 120) as "questionPreview",
count(*)::int as "reportCount",
count(*) filter (where r.priority in ('high', 'urgent'))::int as "highPriority",
max(r.created_at) as "latestAt"
from public.reports r
join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id
left join public.question_versions v on v.id = q.current_version_id
where r.tenant_id = $1
and r.question_id is not null
and r.created_at >= $2::timestamptz
and r.created_at < $3::timestamptz
group by r.question_id, q.type, q.type_label, q.legacy_id, v.content
order by "reportCount" desc, "highPriority" desc, "latestAt" desc
limit $4
`,
[...baseParams, limit],
),
query<Record<string, unknown>>(
`
select r.handled_by as "handlerUserId",
handler.name as "handlerName",
handler.phone as "handlerPhone",
count(*)::int as "handledCount",
count(*) filter (where r.status = 'resolved')::int as resolved,
count(*) filter (where r.status = 'rejected')::int as rejected,
count(*) filter (where r.status = 'closed')::int as closed,
coalesce(avg(extract(epoch from (r.handled_at - r.created_at)) / 3600), 0)::numeric as "avgHandleHours",
max(r.handled_at) as "latestHandledAt"
from public.reports r
left join public.platform_users handler on handler.id = r.handled_by
where r.tenant_id = $1
and r.handled_by is not null
and r.handled_at >= $2::timestamptz
and r.handled_at < $3::timestamptz
and r.status <> 'pending'
group by r.handled_by, handler.name, handler.phone
order by "handledCount" desc, "latestHandledAt" desc
limit $4
`,
[...baseParams, limit],
),
query<Record<string, unknown>>(
`
select r.id, r.question_id as "questionId", q.type as "questionType",
r.user_id as "userId", u.name as "userName", u.phone as "userPhone",
r.type, r.category, r.title, r.status, r.priority,
extract(epoch from (now() - r.created_at)) / 3600 as "ageHours",
r.created_at as "createdAt", r.updated_at as "updatedAt"
from public.reports r
left join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id
left join public.platform_users u on u.id = r.user_id
where r.tenant_id = $1
and r.status in ('pending', 'accepted')
order by case r.priority
when 'urgent' then 1
when 'high' then 2
when 'normal' then 3
else 4
end, r.created_at asc
limit $2
`,
[auth.tenantId, limit],
),
]);
const summaryRow = summary[0] || {};
const total = intValue(summaryRow.total, 0);
const resolved = intValue(summaryRow.resolved, 0);
const closed = intValue(summaryRow.closed, 0);
const rejected = intValue(summaryRow.rejected, 0);
const handledInRange = intValue(summaryRow.handledInRange, 0);
return {
item: {
range,
summary: {
total,
newInRange: total,
pending: intValue(summaryRow.pending, 0),
accepted: intValue(summaryRow.accepted, 0),
rejected,
resolved,
closed,
highPriority: intValue(summaryRow.highPriority, 0),
pendingBacklog: intValue(summaryRow.pendingBacklog, 0),
handledInRange,
rewardedReports: intValue(summaryRow.rewardedReports, 0),
rewardPoints: intValue(summaryRow.rewardPoints, 0),
resolutionRate: ratio(resolved + closed, total),
handledRate: ratio(handledInRange, total),
rejectionRate: ratio(rejected, total),
avgHandleHours: Number(numberValue(summaryRow.avgHandleHours, 0).toFixed(2)),
},
byStatus,
byType,
byCategory,
byPriority,
dailyTrend,
topQuestions,
topHandlers: topHandlers.map(item => ({
...item,
avgHandleHours: Number(numberValue(item.avgHandleHours, 0).toFixed(2)),
})),
recentUnhandled: recentUnhandled.map(item => ({
...item,
ageHours: Number(numberValue(item.ageHours, 0).toFixed(2)),
})),
},
};
}
export async function tenantFeedbackEventsRoute(ctx: RequestContext) { export async function tenantFeedbackEventsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx); const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'feedback:read'); requireTenantPermission(auth, 'feedback:read');

View File

@@ -17,6 +17,7 @@ import {
loadCoupons, loadCoupons,
loadCrmConfig, loadCrmConfig,
loadCrmQueue, loadCrmQueue,
loadFeedbackReport,
loadPointActivityClaims, loadPointActivityClaims,
loadPointActivityTasks, loadPointActivityTasks,
loadPointExchangeItems, loadPointExchangeItems,
@@ -43,6 +44,7 @@ import {
type CouponReport, type CouponReport,
type CrmConfigItem, type CrmConfigItem,
type CrmQueueItem, type CrmQueueItem,
type FeedbackReport,
type PointActivityTaskItem, type PointActivityTaskItem,
type PointExchangeItem, type PointExchangeItem,
type PointsRiskReport, type PointsRiskReport,
@@ -307,6 +309,7 @@ export default function TenantMarketingPage() {
const [pointExchangeItems, setPointExchangeItems] = useState<PointExchangeItem[]>([]); const [pointExchangeItems, setPointExchangeItems] = useState<PointExchangeItem[]>([]);
const [pointExchangeOrders, setPointExchangeOrders] = useState<Record<string, unknown>[]>([]); const [pointExchangeOrders, setPointExchangeOrders] = useState<Record<string, unknown>[]>([]);
const [pointsRiskReport, setPointsRiskReport] = useState<PointsRiskReport | null>(null); const [pointsRiskReport, setPointsRiskReport] = useState<PointsRiskReport | null>(null);
const [feedbackReport, setFeedbackReport] = useState<FeedbackReport | null>(null);
const [crmForm, setCrmForm] = useState({ const [crmForm, setCrmForm] = useState({
enabled: false, enabled: false,
url: '', url: '',
@@ -378,6 +381,7 @@ export default function TenantMarketingPage() {
pointExchangePayload, pointExchangePayload,
pointExchangeOrderPayload, pointExchangeOrderPayload,
pointsRiskPayload, pointsRiskPayload,
feedbackReportPayload,
] = await Promise.all([ ] = await Promise.all([
loadCoupons({ status: couponFilter.status || undefined, campaignName: couponFilter.campaignName || undefined }).catch(() => ({ items: [] })), loadCoupons({ status: couponFilter.status || undefined, campaignName: couponFilter.campaignName || undefined }).catch(() => ({ items: [] })),
loadCouponReport({ loadCouponReport({
@@ -411,6 +415,7 @@ export default function TenantMarketingPage() {
limit: 30, limit: 30,
}).catch(() => ({ items: [] })), }).catch(() => ({ items: [] })),
loadPointsRiskReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })), loadPointsRiskReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })),
loadFeedbackReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })),
]); ]);
const nextCrm = crmConfigPayload.item || null; const nextCrm = crmConfigPayload.item || null;
const nextSettings = commissionSettingsPayload.item || null; const nextSettings = commissionSettingsPayload.item || null;
@@ -433,6 +438,7 @@ export default function TenantMarketingPage() {
setPointExchangeItems(pointExchangePayload.items || []); setPointExchangeItems(pointExchangePayload.items || []);
setPointExchangeOrders(pointExchangeOrderPayload.items || []); setPointExchangeOrders(pointExchangeOrderPayload.items || []);
setPointsRiskReport(pointsRiskPayload.item || null); setPointsRiskReport(pointsRiskPayload.item || null);
setFeedbackReport(feedbackReportPayload.item || null);
if (nextCrm) { if (nextCrm) {
setCrmForm({ setCrmForm({
enabled: nextCrm.enabled === true, enabled: nextCrm.enabled === true,
@@ -610,7 +616,7 @@ export default function TenantMarketingPage() {
setBusy('points'); setBusy('points');
setError(''); setError('');
try { try {
const [taskPayload, claimPayload, exchangePayload, orderPayload, riskPayload] = await Promise.all([ const [taskPayload, claimPayload, exchangePayload, orderPayload, riskPayload, feedbackReportPayload] = await Promise.all([
loadPointActivityTasks({ status: nextFilter.taskStatus || undefined, limit: 100 }), loadPointActivityTasks({ status: nextFilter.taskStatus || undefined, limit: 100 }),
loadPointActivityClaims({ loadPointActivityClaims({
taskId: nextFilter.selectedTaskId || undefined, taskId: nextFilter.selectedTaskId || undefined,
@@ -625,12 +631,14 @@ export default function TenantMarketingPage() {
limit: 50, limit: 50,
}).catch(() => ({ items: [] })), }).catch(() => ({ items: [] })),
loadPointsRiskReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })), loadPointsRiskReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })),
loadFeedbackReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })),
]); ]);
setPointTasks(taskPayload.items || []); setPointTasks(taskPayload.items || []);
setPointClaims(claimPayload.items || []); setPointClaims(claimPayload.items || []);
setPointExchangeItems(exchangePayload.items || []); setPointExchangeItems(exchangePayload.items || []);
setPointExchangeOrders(orderPayload.items || []); setPointExchangeOrders(orderPayload.items || []);
setPointsRiskReport(riskPayload.item || null); setPointsRiskReport(riskPayload.item || null);
setFeedbackReport(feedbackReportPayload.item || null);
} catch (nextError) { } catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '积分运营数据加载失败'); setError(nextError instanceof Error ? nextError.message : '积分运营数据加载失败');
} finally { } finally {
@@ -980,6 +988,39 @@ export default function TenantMarketingPage() {
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{money(commission?.commissionAmountCents)}</Text></View> <View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{money(commission?.commissionAmountCents)}</Text></View>
</View> </View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-grid'>
<View className='admin-metric'><Text className='admin-metric-label'>30 </Text><Text className='admin-metric-value'>{String(feedbackReport?.summary?.total || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(feedbackReport?.summary?.pendingBacklog || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{percentLabel(feedbackReport?.summary?.handledRate)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(feedbackReport?.summary?.rewardPoints || 0)}</Text></View>
</View>
<View className='admin-grid'>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(feedbackReport?.summary?.highPriority || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String((feedbackReport?.summary?.resolved || 0) + (feedbackReport?.summary?.closed || 0))}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(feedbackReport?.summary?.avgHandleHours || 0)}h</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{percentLabel(feedbackReport?.summary?.resolutionRate)}</Text></View>
</View>
<View className='admin-list'>
{(feedbackReport?.recentUnhandled || []).slice(0, 4).map((item, index) => (
<View className='admin-row' key={String(item.id || index)}>
<Text className='admin-row-main'>{String(item.title || item.type || '待处理反馈')} · {String(item.priority || 'normal')}</Text>
<Text className='admin-row-meta'>{String(item.userName || item.userPhone || item.userId || '学生')} · {String(item.status || '-')} · {String(item.ageHours || 0)}h</Text>
</View>
))}
{(feedbackReport?.topQuestions || []).slice(0, 4).map((item, index) => (
<View className='admin-row' key={String(item.questionId || index)}>
<Text className='admin-row-main'>{String(item.questionPreview || item.questionId || '高频反馈题目')}</Text>
<Text className='admin-row-meta'>{String(item.questionType || '题目')} · {String(item.reportCount || 0)} · {String(item.highPriority || 0)}</Text>
</View>
))}
</View>
{!(feedbackReport?.recentUnhandled?.length || feedbackReport?.topQuestions?.length) ? (
<View className='admin-empty'> 30 `feedback:read` </View>
) : null}
</View>
<View className='admin-section'> <View className='admin-section'>
<Text className='admin-section-title'></Text> <Text className='admin-section-title'></Text>
<View className='admin-grid'> <View className='admin-grid'>

View File

@@ -606,6 +606,40 @@ export interface PointsRiskReport {
dailyTrend?: Array<Record<string, unknown>>; dailyTrend?: Array<Record<string, unknown>>;
} }
export interface FeedbackReport {
range?: {
timeRange?: '7d' | '30d' | '90d';
startDate?: string;
endDate?: string;
};
summary?: {
total?: number;
newInRange?: number;
pending?: number;
accepted?: number;
rejected?: number;
resolved?: number;
closed?: number;
highPriority?: number;
pendingBacklog?: number;
handledInRange?: number;
rewardedReports?: number;
rewardPoints?: number;
resolutionRate?: number;
handledRate?: number;
rejectionRate?: number;
avgHandleHours?: number;
};
byStatus?: Array<Record<string, unknown>>;
byType?: Array<Record<string, unknown>>;
byCategory?: Array<Record<string, unknown>>;
byPriority?: Array<Record<string, unknown>>;
dailyTrend?: Array<Record<string, unknown>>;
topQuestions?: Array<Record<string, unknown>>;
topHandlers?: Array<Record<string, unknown>>;
recentUnhandled?: Array<Record<string, unknown>>;
}
export interface CodeBatchItem { export interface CodeBatchItem {
id: string; id: string;
name?: string | null; name?: string | null;
@@ -1282,6 +1316,15 @@ export async function loadPointsRiskReport(query: {
}); });
} }
export async function loadFeedbackReport(query: {
timeRange?: '7d' | '30d' | '90d';
limit?: number;
} = {}) {
return apiRequest<{ item?: FeedbackReport }>('/api/tenant-admin/feedbacks/report', {
query: { ...query, limit: query.limit || 20 },
});
}
export async function loadCodeBatches() { export async function loadCodeBatches() {
return apiRequest<{ items?: CodeBatchItem[] }>('/api/tenant-admin/code-batches'); return apiRequest<{ items?: CodeBatchItem[] }>('/api/tenant-admin/code-batches');
} }

View File

@@ -64,10 +64,10 @@
| 模考交卷报告 | 可联调 | `POST /api/learning/practice-sessions/submit``GET /api/learning/practice-sessions/report``GET /api/learning/practice-reports`;后端按 session 快照评分、分段统计、错题解析汇总,复合题返回 `questionResults[].subResults` 和部分得分,重复提交幂等 | | 模考交卷报告 | 可联调 | `POST /api/learning/practice-sessions/submit``GET /api/learning/practice-sessions/report``GET /api/learning/practice-reports`;后端按 session 快照评分、分段统计、错题解析汇总,复合题返回 `questionResults[].subResults` 和部分得分,重复提交幂等 |
| 学习历史/统计/趋势 | 可联调 | `GET /api/learning/practice-sessions/history``GET /api/learning/stats``GET /api/learning/trend`;可支撑个人中心、练习历史、正确率趋势和题型分布 | | 学习历史/统计/趋势 | 可联调 | `GET /api/learning/practice-sessions/history``GET /api/learning/stats``GET /api/learning/trend`;可支撑个人中心、练习历史、正确率趋势和题型分布 |
| 错题复习计划 | 可联调 | `GET /api/learning/wrong-questions/review-plan` + `POST /api/learning/practice-sessions``mode=wrong_review`,后端从本人错题本安全组卷 | | 错题复习计划 | 可联调 | `GET /api/learning/wrong-questions/review-plan` + `POST /api/learning/practice-sessions``mode=wrong_review`,后端从本人错题本安全组卷 |
| 学习排行榜 | 可联调 | `GET /api/learning/leaderboard`;租户 `feature_flags.enableLeaderboard` 默认关闭,开启后支持 `questions``score``vocabulary``mock_exam` 四类指标,支持 `all``7d``30d` 周期和租户/地区/班级范围,返回当前用户排名并拒绝跨租户 session | | 学习排行榜 | 可联调 | `GET /api/learning/leaderboard`;租户 `feature_flags.enableLeaderboard` 默认关闭,开启后支持 `questions``score``vocabulary``mock_exam` 四类指标,支持 `all``7d``30d` 周期和租户/地区/班级范围,返回当前用户排名并拒绝跨租户 session;当前产品默认不在学生端请求排行榜,日常激励以后台配置勋章自动发放为主 |
| 考试倒计时 | 可联调 | `GET /api/catalog/exam-dates``GET /api/profile/exam-countdowns`;返回租户/地区匹配考试日期和 `daysLeft` | | 考试倒计时 | 可联调 | `GET /api/catalog/exam-dates``GET /api/profile/exam-countdowns`;返回租户/地区匹配考试日期和 `daysLeft` |
| 学生预设头像 | 可联调 | `GET/PATCH /api/profile/me`;仅支持 `avatarPreset=male/female` 和系统默认头像路径,后端拒绝直接 `PATCH avatarUrl`,不提供用户头像上传 | | 学生预设头像 | 可联调 | `GET/PATCH /api/profile/me`;仅支持 `avatarPreset=male/female` 和系统默认头像路径,后端拒绝直接 `PATCH avatarUrl`,不提供用户头像上传;微信/QQ 登录和 PocketBase 导入脚本均不会把第三方头像 URL 写入学生资料 |
| 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转,处理结果和奖励积分会写入用户站内通知 | | 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转,处理结果和奖励积分会写入用户站内通知`GET /api/tenant-admin/feedbacks/report` 已提供 7/30/90 天只读运营聚合,包含状态/类型/分类/优先级分布、处理效率、奖励积分、高频题目和待处理积压 |
| 签到积分 | 可联调 | `POST /api/profile/check-in``GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分;租户可用 `taskType=daily_check_in` 积分任务配置基础/连续签到奖励,后端自动写积分流水和领取记录;真实签到成功会触发 `check_in``score` 规则勋章自动发放,重复签到不重复发放 | | 签到积分 | 可联调 | `POST /api/profile/check-in``GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分;租户可用 `taskType=daily_check_in` 积分任务配置基础/连续签到奖励,后端自动写积分流水和领取记录;真实签到成功会触发 `check_in``score` 规则勋章自动发放,重复签到不重复发放 |
| 积分活动/兑换 | 可联调 | `GET /api/profile/activity-tasks``POST /api/profile/activity-tasks/claim``GET /api/profile/exchange-items``POST /api/profile/exchange-items/redeem``GET /api/tenant-admin/points-risk-report`;复用 `user_score_events` 积分账本,任务领取和兑换均事务加锁;手动/练习/单词/模考类任务有后端证据校验,反馈解决等系统任务禁止学生自领;兑换支持库存、个人限购、余额校验、幂等 key、优惠券履约、兑换订单和完成/待履约站内通知;租户后台积分风控报表按 7/30/90 天只读聚合异常用户、大额积分事件、高频任务和大额兑换,不直接改账、封禁或冻结权益 | | 积分活动/兑换 | 可联调 | `GET /api/profile/activity-tasks``POST /api/profile/activity-tasks/claim``GET /api/profile/exchange-items``POST /api/profile/exchange-items/redeem``GET /api/tenant-admin/points-risk-report`;复用 `user_score_events` 积分账本,任务领取和兑换均事务加锁;手动/练习/单词/模考类任务有后端证据校验,反馈解决等系统任务禁止学生自领;兑换支持库存、个人限购、余额校验、幂等 key、优惠券履约、兑换订单和完成/待履约站内通知;租户后台积分风控报表按 7/30/90 天只读聚合异常用户、大额积分事件、高频任务和大额兑换,不直接改账、封禁或冻结权益 |
| 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态;签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩规则已支持自动发放,自动/手动获得勋章会写入用户站内通知 | | 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态;签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩规则已支持自动发放,自动/手动获得勋章会写入用户站内通知 |
@@ -140,7 +140,7 @@
| 积分任务/兑换配置 | 可联调 | `/api/tenant-admin/point-activity-tasks``point-activity-claims``point-exchange-items``point-exchange-orders``points-risk-report`;使用 `marketing:points:read/write` 或兼容 `marketing:read/write` 权限,积分风控支持 `marketing:points:risk:read` 且兼容 `marketing:points:read`;支持任务配置、兑换商品配置、领取记录、兑换订单、只读风控报表、审计和跨租户拒绝 | | 积分任务/兑换配置 | 可联调 | `/api/tenant-admin/point-activity-tasks``point-activity-claims``point-exchange-items``point-exchange-orders``points-risk-report`;使用 `marketing:points:read/write` 或兼容 `marketing:read/write` 权限,积分风控支持 `marketing:points:risk:read` 且兼容 `marketing:points:read`;支持任务配置、兑换商品配置、领取记录、兑换订单、只读风控报表、审计和跨租户拒绝 |
| 勋章管理/发放 | 可联调 | `/api/tenant-admin/badges``/api/tenant-admin/badge-grants`;支持后台维护、同 `legacyId` 幂等更新、手动发放、重复发放幂等、租户隔离和权限点 `badges:read/write/grant``unlockType=check_in/score/feedback_resolved/activity_reward/practice_count/vocabulary_mastered/mock_exam_score` 会由签到、积分奖励、反馈解决、活动任务、练习交卷、单词掌握和模考成绩事件自动发放,并写入用户站内通知 | | 勋章管理/发放 | 可联调 | `/api/tenant-admin/badges``/api/tenant-admin/badge-grants`;支持后台维护、同 `legacyId` 幂等更新、手动发放、重复发放幂等、租户隔离和权限点 `badges:read/write/grant``unlockType=check_in/score/feedback_resolved/activity_reward/practice_count/vocabulary_mastered/mock_exam_score` 会由签到、积分奖励、反馈解决、活动任务、练习交卷、单词掌握和模考成绩事件自动发放,并写入用户站内通知 |
| 考试日期维护 | 可联调 | `/api/tenant-admin/exam-dates`,支持地区维度维护和公开倒计时展示 | | 考试日期维护 | 可联调 | `/api/tenant-admin/exam-dates`,支持地区维度维护和公开倒计时展示 |
| 题目反馈处理 | 可联调 | `/api/tenant-admin/feedbacks``feedbacks/status``feedbacks/events`;支持状态流转、处理备注、审计事件、幂等奖励积分用户站内通知 | | 题目反馈处理 | 可联调 | `/api/tenant-admin/feedbacks``feedbacks/report``feedbacks/status``feedbacks/events`;支持状态流转、处理备注、审计事件、幂等奖励积分用户站内通知和只读运营聚合报表 |
| 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches``activation-codes` | | 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches``activation-codes` |
| 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members``permissions``role-templates``audit-logs` | | 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members``permissions``role-templates``audit-logs` |
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes``classes/members``students``teachers`,支持班级范围权限和审计 | | 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes``classes/members``students``teachers`,支持班级范围权限和审计 |

View File

@@ -10,7 +10,7 @@
- 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费、逾期催缴和用量。 - 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费、逾期催缴和用量。
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。 - 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、男女预设头像、站内通知、勋章、排行榜接口(租户默认关闭)、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API签到、积分阈值、反馈解决和积分活动可触发自动勋章发放反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。 - 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、男女预设头像、站内通知、勋章、排行榜接口(租户默认关闭)、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API签到、积分阈值、反馈解决和积分活动可触发自动勋章发放反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知;租户后台已具备反馈运营聚合报表和积分风控只读报表
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。 - 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。 - 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。

View File

@@ -15,7 +15,7 @@
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。 - `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。 - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。
- `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/告警状态流、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、使用量。 - `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/告警状态流、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、使用量。
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理和运营报表、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
- `tenant`:域名/租户解析。 - `tenant`:域名/租户解析。
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。 - 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
@@ -23,7 +23,7 @@
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。 - 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
- 班级与学生范围权限已落库:`tenant_classes``tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 班级与学生范围权限已落库:`tenant_classes``tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
- 学生运营管理已落库:`tenant_student_notes``tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。 - 学生运营管理已落库:`tenant_student_notes``tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
- 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转;`user_score_events` 支持每日签到积分流水、租户配置化连续签到奖励和反馈奖励幂等;`badges/user_badges` 支持租户后台勋章维护、手动发放、重复发放幂等和学生个人中心展示;`user_notifications` 支持反馈处理、反馈奖励、勋章发放和积分兑换的学生站内通知Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/每日签到奖励/兑换操作台和用户通知查看第一版。 - 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转和 7/30/90 天只读反馈运营报表`user_score_events` 支持每日签到积分流水、租户配置化连续签到奖励和反馈奖励幂等;`badges/user_badges` 支持租户后台勋章维护、手动发放、重复发放幂等和学生个人中心展示;`user_notifications` 支持反馈处理、反馈奖励、勋章发放和积分兑换的学生站内通知Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接反馈运营摘要、积分任务/每日签到奖励/兑换操作台和用户通知查看第一版。
- 旧题库排行榜主链路已补齐但租户默认关闭:`GET /api/learning/leaderboard` 只有在 `feature_flags.enableLeaderboard=true` 时可用,支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。 - 旧题库排行榜主链路已补齐但租户默认关闭:`GET /api/learning/leaderboard` 只有在 `feature_flags.enableLeaderboard=true` 时可用,支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。
- `learning` 已接入商用访问控制免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `learning` 已接入商用访问控制免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
@@ -244,6 +244,7 @@ POST /api/tenant-admin/badge-grants
GET /api/tenant-admin/exam-dates GET /api/tenant-admin/exam-dates
PUT /api/tenant-admin/exam-dates PUT /api/tenant-admin/exam-dates
GET /api/tenant-admin/feedbacks GET /api/tenant-admin/feedbacks
GET /api/tenant-admin/feedbacks/report
POST /api/tenant-admin/feedbacks/status POST /api/tenant-admin/feedbacks/status
GET /api/tenant-admin/feedbacks/events GET /api/tenant-admin/feedbacks/events
GET /api/tenant-admin/code-batches GET /api/tenant-admin/code-batches

View File

@@ -46,7 +46,7 @@
- 上线前需要把三套 H5 构建、`runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate` - 上线前需要把三套 H5 构建、`runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`
- 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve``branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme` - 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve``branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`
- 租户后台可以接入角色模板和成员 API`/api/tenant-admin/role-templates``/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。 - 租户后台可以接入角色模板和成员 API`/api/tenant-admin/role-templates``/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。
- 租户后台可以接入勋章管理、手动发放、积分任务、每日签到奖励、积分兑换积分风控只读报表:`GET/PUT /api/tenant-admin/badges``GET/POST /api/tenant-admin/badge-grants``GET/PUT /api/tenant-admin/point-activity-tasks``GET /api/tenant-admin/point-activity-claims``GET/PUT /api/tenant-admin/point-exchange-items``GET /api/tenant-admin/point-exchange-orders``GET /api/tenant-admin/points-risk-report`;学生端用 `GET /api/profile/badges` 展示成就,通过 `POST /api/profile/check-in` 自动领取 `daily_check_in` 任务奖励,并通过 `GET /api/profile/activity-tasks``POST /api/profile/activity-tasks/claim``GET /api/profile/exchange-items``POST /api/profile/exchange-items/redeem` 接其它积分活动和兑换。 - 租户后台可以接入勋章管理、手动发放、积分任务、每日签到奖励、积分兑换积分风控只读报表和反馈运营报表`GET/PUT /api/tenant-admin/badges``GET/POST /api/tenant-admin/badge-grants``GET/PUT /api/tenant-admin/point-activity-tasks``GET /api/tenant-admin/point-activity-claims``GET/PUT /api/tenant-admin/point-exchange-items``GET /api/tenant-admin/point-exchange-orders``GET /api/tenant-admin/points-risk-report``GET /api/tenant-admin/feedbacks/report`;学生端用 `GET /api/profile/badges` 展示成就,通过 `POST /api/profile/check-in` 自动领取 `daily_check_in` 任务奖励,并通过 `GET /api/profile/activity-tasks``POST /api/profile/activity-tasks/claim``GET /api/profile/exchange-items``POST /api/profile/exchange-items/redeem` 接其它积分活动和兑换。
- 学生消息中心可以接 `GET /api/profile/notifications``POST /api/profile/notifications/status`;租户后台可用 `GET /api/tenant-admin/user-notifications` 做用户通知查看。通知只做展示、跳转和已读/归档状态,业务权限和权益仍以后端源接口为准。 - 学生消息中心可以接 `GET /api/profile/notifications``POST /api/profile/notifications/status`;租户后台可用 `GET /api/tenant-admin/user-notifications` 做用户通知查看。通知只做展示、跳转和已读/归档状态,业务权限和权益仍以后端源接口为准。
## 不能误认为已商用完成的部分 ## 不能误认为已商用完成的部分
@@ -96,11 +96,11 @@
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `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/teachers``tenant-admin/students``students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups` | | 学生运营 | `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/exports/questions``tenant-content/exports/jobs``tenant-content/assets/sign-download``tenant-content/assets/sign-preview``tenant-content/assets/security-scan-events``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/content/index.tsx` | `tenant-content/content-entries``tenant-content/imports``imports/detail``imports/issues``imports/field-mapping``imports/templates``imports/post-check``tenant-content/exports/questions``tenant-content/exports/jobs``tenant-content/assets/sign-download``tenant-content/assets/sign-preview``tenant-content/assets/security-scan-events``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``tenant-admin/point-activity-tasks``tenant-admin/point-activity-claims``tenant-admin/point-exchange-items``tenant-admin/point-exchange-orders``tenant-admin/points-risk-report``tenant-admin/user-notifications`;已接 CRM、分佣、优惠券规则/核销报表、积分任务/兑换配置与记录查看、积分风控摘要、用户通知查看第一版 | | 营销中心 | `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``tenant-admin/feedbacks/report``tenant-admin/point-activity-tasks``tenant-admin/point-activity-claims``tenant-admin/point-exchange-items``tenant-admin/point-exchange-orders``tenant-admin/points-risk-report``tenant-admin/user-notifications`;已接 CRM、分佣、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置与记录查看、积分风控摘要、用户通知查看第一版 |
| 财务运营 | `apps/taro/src/pages/tenant-admin/finance/index.tsx` | `commerce/refunds``commerce/refunds/status``commerce/operations/anomalies``commerce/reconciliation/batches``commerce/reconciliation/items``commerce/reconciliation/issues/create``commerce/reconciliation/issues``commerce/reconciliation/issues/status``commerce/reconciliation/provider-bills/request``commerce/reconciliation/provider-bills/jobs``commerce/adjustment-vouchers``commerce/adjustment-vouchers/status``commerce/adjustment-vouchers/report` | | 财务运营 | `apps/taro/src/pages/tenant-admin/finance/index.tsx` | `commerce/refunds``commerce/refunds/status``commerce/operations/anomalies``commerce/reconciliation/batches``commerce/reconciliation/items``commerce/reconciliation/issues/create``commerce/reconciliation/issues``commerce/reconciliation/issues/status``commerce/reconciliation/provider-bills/request``commerce/reconciliation/provider-bills/jobs``commerce/adjustment-vouchers``commerce/adjustment-vouchers/status``commerce/adjustment-vouchers/report` |
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview``domains``payment-accounts``auth-providers``theme-templates``theme``theme/preview``theme/publish``permissions``GET/PUT role-templates``POST role-templates/disable``GET/PUT members``POST members/disable` | | 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview``domains``payment-accounts``auth-providers``theme-templates``theme``theme/preview``theme/publish``permissions``GET/PUT role-templates``POST role-templates/disable``GET/PUT members``POST members/disable` |
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则/核销报表、积分任务/兑换配置、领取/兑换记录查看、积分风控只读摘要和用户通知查看第一版;财务运营页支持退款状态流、官方账单任务、对账异常、差错工单和调整凭证,且只通过后端命令层写审计与状态;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、主题素材库、真实打款 provider、发票、真实生产账单抽样验收和小程序端兼容。 当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置、领取/兑换记录查看、积分风控只读摘要和用户通知查看第一版;财务运营页支持退款状态流、官方账单任务、对账异常、差错工单和调整凭证,且只通过后端命令层写审计与状态;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、主题素材库、真实打款 provider、发票、真实生产账单抽样验收和小程序端兼容。
## 已落地的 Taro 平台后台页面 ## 已落地的 Taro 平台后台页面

View File

@@ -256,6 +256,7 @@ tenant-admin:
GET /api/tenant-admin/exam-dates GET /api/tenant-admin/exam-dates
PUT /api/tenant-admin/exam-dates PUT /api/tenant-admin/exam-dates
GET /api/tenant-admin/feedbacks GET /api/tenant-admin/feedbacks
GET /api/tenant-admin/feedbacks/report
POST /api/tenant-admin/feedbacks/status POST /api/tenant-admin/feedbacks/status
GET /api/tenant-admin/feedbacks/events GET /api/tenant-admin/feedbacks/events
GET /api/tenant-admin/code-batches GET /api/tenant-admin/code-batches

View File

@@ -31,12 +31,12 @@
| 知识手册 | `Handbook*.tsx` | 已覆盖 | Taro 已接手册科目/章节/条目、RichContent 安全渲染、H5 KaTeX 公式渲染和私有资源 ID 短签名地基第一版;后续补搜索、高亮、目录锚点、小程序公式真机验收和图片资源字段化 | | 知识手册 | `Handbook*.tsx` | 已覆盖 | Taro 已接手册科目/章节/条目、RichContent 安全渲染、H5 KaTeX 公式渲染和私有资源 ID 短签名地基第一版;后续补搜索、高亮、目录锚点、小程序公式真机验收和图片资源字段化 |
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 | | 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 |
| 商城/SVIP | `Store.tsx``SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、微信/支付宝官方账单下载 worker、异常查询、差错工单和事件轨迹、异常订单运营台、人工调整凭证复核报表已有Taro 学生收银台/订单详情和租户财务运营台第一版已接;仍缺更完整售后体验、真实生产账单抽样验收和生产账号联调 | | 商城/SVIP | `Store.tsx``SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、微信/支付宝官方账单下载 worker、异常查询、差错工单和事件轨迹、异常订单运营台、人工调整凭证复核报表已有Taro 学生收银台/订单详情和租户财务运营台第一版已接;仍缺更完整售后体验、真实生产账单抽样验收和生产账号联调 |
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、男女预设头像、权益、订单统计、练习历史、学习统计、签到积分、积分活动任务、积分兑换、考试倒计时、趋势、勋章展示和站内通知 API 已有Taro 个人中心已接学习报告、积分任务、积分兑换、积分明细、消息筛选/已读/归档第一版;继续补账号合并、更细学习建议和预设头像 UI 接入 | | 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、男女预设头像、权益、订单统计、练习历史、学习统计、签到积分、积分活动任务、积分兑换、考试倒计时、趋势、勋章展示和站内通知 API 已有Taro 个人中心已接学习报告、积分任务、积分兑换、积分明细、消息筛选/已读/归档第一版;后端登录和导入链路不会保存微信/QQ/旧系统头像 URL继续补账号合并、更细学习建议和预设头像 UI 接入 |
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、Taro 学生端水印 traceId 预览/下载确认、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;仍缺深度防盗链、真实 AV/内容安全服务联调、转码/CDN 级水印和生命周期策略 | | 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、Taro 学生端水印 traceId 预览/下载确认、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;仍缺深度防盗链、真实 AV/内容安全服务联调、转码/CDN 级水印和生命周期策略 |
| AI 择校推荐 | 业务规划新增 | 部分覆盖 | 已有 SVIP 门禁、学生输入 schema、地区/分数线上下文、稳定 JSON 输出、报告台账、审计和 Taro 学生端基础页;真实 AI provider、prompt 版本管理后台、报告 PDF 渲染和更细推荐算法待补 | | AI 择校推荐 | 业务规划新增 | 部分覆盖 | 已有 SVIP 门禁、学生输入 schema、地区/分数线上下文、稳定 JSON 输出、报告台账、审计和 Taro 学生端基础页;真实 AI provider、prompt 版本管理后台、报告 PDF 渲染和更细推荐算法待补 |
| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分站内通知已覆盖Taro 个人中心消息中心可查看反馈处理/奖励通知;仍缺外部订阅消息/短信提醒、批量统计和内容修复闭环 | | 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分站内通知和租户反馈运营聚合报表已覆盖Taro 个人中心消息中心可查看反馈处理/奖励通知,租户营销中心已接 30 天反馈运营摘要;仍缺外部订阅消息/短信提醒和内容修复闭环 |
| 签到积分 | `Profile.tsx``02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等、签到/积分阈值自动发放勋章、积分活动任务、积分兑换商品、优惠券兑换履约、租户后台配置和积分风控只读报表已覆盖;后续补活动效果看板和更多系统触发任务 | | 签到积分 | `Profile.tsx``02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等、签到/积分阈值自动发放勋章、积分活动任务、积分兑换商品、优惠券兑换履约、租户后台配置和积分风控只读报表已覆盖;学生激励默认以勋章自动发放为主,排行榜不默认开启;后续补更细活动效果看板和更多系统触发任务 |
| 排行榜 | `leaderboard.pb.js``02-API接口.md` | 部分覆盖 | 已有刷题数、积分、背单词、模考最高分排行榜,支持租户/地区/班级范围和当前用户排名;后补防刷、日/周榜预聚合、运营后台排名看板 | | 排行榜 | `leaderboard.pb.js``02-API接口.md` | 部分覆盖 | 已有刷题数、积分、背单词、模考最高分排行榜,支持租户/地区/班级范围和当前用户排名;租户默认关闭,学生端默认不请求;后续仅在明确开启活动并压测后补防刷、日/周榜预聚合、运营后台排名看板 |
## 租户后台功能 ## 租户后台功能

View File

@@ -21,9 +21,9 @@
- 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。 - 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。 - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。 - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、反馈运营聚合报表、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。学生激励以后台配置勋章自动发放为主,不默认启用排行榜竞争。后续补活动效果看板和更细前端运营配置体验。 - 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。学生激励以后台配置勋章自动发放为主,不默认启用排行榜竞争。后续补更细活动效果看板和更细前端运营配置体验。
- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单兑换完成或待履约会写入用户站内通知Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台和积分风控只读摘要第一版。后续补更细活动效果看板。 - 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单兑换完成或待履约会写入用户站内通知Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台、反馈运营摘要和积分风控只读摘要第一版。后续补更细活动效果看板。
- 用户站内通知第一版已完成:反馈处理、反馈奖励、勋章发放、积分兑换会创建 `user_notifications`;学生端可查询/标记状态,租户后台具备 `notifications:read` 权限的成员可查看租户内通知Taro 学生个人中心已接消息筛选、已读和归档第一版,租户营销中心已接用户通知查看和筛选第一版。后续补独立消息中心增强、外部微信订阅消息/短信和批量统计。 - 用户站内通知第一版已完成:反馈处理、反馈奖励、勋章发放、积分兑换会创建 `user_notifications`;学生端可查询/标记状态,租户后台具备 `notifications:read` 权限的成员可查看租户内通知Taro 学生个人中心已接消息筛选、已读和归档第一版,租户营销中心已接用户通知查看和筛选第一版。后续补独立消息中心增强、外部微信订阅消息/短信和批量统计。
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
- 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。 - 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。
@@ -145,7 +145,7 @@
9. 积分和反馈增强 9. 积分和反馈增强
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
- 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩自动发放。 - 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩自动发放。
- 积分兑换、活动任务、优惠券兑换履约、积分风控只读报表和站内通知后端第一阶段已完成Taro 学生个人中心和租户营销中心已接积分任务/兑换第一版,租户营销中心已接 30 天风控摘要;继续补独立消息中心增强、外部订阅消息/短信、反馈聚合统计和更细活动效果看板。 - 积分兑换、活动任务、优惠券兑换履约、积分风控只读报表、反馈运营聚合报表和站内通知后端第一阶段已完成Taro 学生个人中心和租户营销中心已接积分任务/兑换第一版,租户营销中心已接 30 天风控摘要和反馈运营摘要;继续补独立消息中心增强、外部订阅消息/短信和更细活动效果看板。
10. 数据看板 10. 数据看板
- 已完成首版实时聚合接口覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。 - 已完成首版实时聚合接口覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。

View File

@@ -253,7 +253,7 @@ tenant:<tenantId>:theme
| 错题复习 | `GET /api/learning/wrong-questions/review-plan``POST /api/learning/practice-sessions` with `mode=wrong_review` | | 错题复习 | `GET /api/learning/wrong-questions/review-plan``POST /api/learning/practice-sessions` with `mode=wrong_review` |
| 收藏夹 | `GET/POST /api/learning/favorites/questions` | | 收藏夹 | `GET/POST /api/learning/favorites/questions` |
| 练习历史/统计 | `GET /api/learning/practice-sessions/history``GET /api/learning/stats``GET /api/learning/trend` | | 练习历史/统计 | `GET /api/learning/practice-sessions/history``GET /api/learning/stats``GET /api/learning/trend` |
| 学习排行榜 | `GET /api/learning/leaderboard?metric=questions&period=all`,仅当租户 `features.enableLeaderboard=true` 时启用 | | 学习排行榜 | `GET /api/learning/leaderboard?metric=questions&period=all`,仅当租户 `features.enableLeaderboard=true` 时启用;默认不请求、不展示 |
| 题目视频 | `GET /api/questions/{questionId}/videos``POST /api/questions/videos/batch``POST /api/videos/play` | | 题目视频 | `GET /api/questions/{questionId}/videos``POST /api/questions/videos/batch``POST /api/videos/play` |
| 题目反馈 | `POST /api/profile/feedbacks``GET /api/profile/feedbacks` | | 题目反馈 | `POST /api/profile/feedbacks``GET /api/profile/feedbacks` |
| 分佣结算 | `GET /api/commission/settings``PUT /api/commission/settings``PUT /api/commission/member-rate``GET /api/commission/summary``GET /api/commission/orders``GET /api/commission/settlements``POST /api/commission/settlements/generate``POST /api/commission/settlements/status` | | 分佣结算 | `GET /api/commission/settings``PUT /api/commission/settings``PUT /api/commission/member-rate``GET /api/commission/summary``GET /api/commission/orders``GET /api/commission/settlements``POST /api/commission/settlements/generate``POST /api/commission/settlements/status` |
@@ -277,7 +277,7 @@ tenant:<tenantId>:theme
| 学生跟进任务 | `GET/PUT /api/tenant-admin/students/followups` | | 学生跟进任务 | `GET/PUT /api/tenant-admin/students/followups` |
| 租户教师 | `GET /api/tenant-admin/teachers` | | 租户教师 | `GET /api/tenant-admin/teachers` |
| 租户考试日期 | `GET/PUT /api/tenant-admin/exam-dates` | | 租户考试日期 | `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 /api/tenant-admin/feedbacks``GET /api/tenant-admin/feedbacks/report``POST /api/tenant-admin/feedbacks/status``GET /api/tenant-admin/feedbacks/events` |
| 租户勋章 | `GET/PUT /api/tenant-admin/badges``GET/POST /api/tenant-admin/badge-grants` | | 租户勋章 | `GET/PUT /api/tenant-admin/badges``GET/POST /api/tenant-admin/badge-grants` |
| 租户积分任务/兑换 | `GET/PUT /api/tenant-admin/point-activity-tasks``GET /api/tenant-admin/point-activity-claims``GET/PUT /api/tenant-admin/point-exchange-items``GET /api/tenant-admin/point-exchange-orders` | | 租户积分任务/兑换 | `GET/PUT /api/tenant-admin/point-activity-tasks``GET /api/tenant-admin/point-activity-claims``GET/PUT /api/tenant-admin/point-exchange-items``GET /api/tenant-admin/point-exchange-orders` |
| 公共题库采纳/同步 | `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/public-question-banks/conflicts/resolve-batch` | | 公共题库采纳/同步 | `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/public-question-banks/conflicts/resolve-batch` |
@@ -1125,7 +1125,7 @@ GET /api/tenant-content/assets/security-scan-events?assetId=<assetId>&limit=100
## 个人中心预设头像 ## 个人中心预设头像
学生端不支持用户上传头像。系统只提供男女两种默认头像预设,避免头像上传带来的存储、内容安全、隐私和审核复杂度。前端不要展示文件选择器,不要调用对象存储上传链路,也不要把微信/QQ 返回的头像 URL 写入学生资料。 学生端不支持用户上传头像。系统只提供男女两种默认头像预设,避免头像上传带来的存储、内容安全、隐私和审核复杂度。前端不要展示文件选择器,不要调用对象存储上传链路,也不要把微信/QQ 返回的头像 URL 写入学生资料。后端登录链路和 PocketBase 导入脚本也会丢弃第三方头像字段,学生展示头像必须来自 `avatarPreset`
当前 Taro service 使用: 当前 Taro service 使用:
@@ -1347,6 +1347,38 @@ GET /api/tenant-admin/points-risk-report?timeRange=30d&limit=20
- 反馈状态由租户后台处理,学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。 - 反馈状态由租户后台处理,学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。
- 租户后台处理反馈时,奖励积分由后端 `idempotency_key` 保证不会重复发放,前端不要重复叠加;状态处理响应可能返回 `autoBadges`。反馈状态、反馈奖励和自动勋章提醒会由后端写入学生站内通知,前端消息中心只负责展示和标记状态。 - 租户后台处理反馈时,奖励积分由后端 `idempotency_key` 保证不会重复发放,前端不要重复叠加;状态处理响应可能返回 `autoBadges`。反馈状态、反馈奖励和自动勋章提醒会由后端写入学生站内通知,前端消息中心只负责展示和标记状态。
租户后台反馈运营报表:
```text
GET /api/tenant-admin/feedbacks/report?timeRange=30d&limit=20
```
`timeRange` 仅支持 `7d``30d``90d`。该接口是只读运营聚合,不会修改反馈、积分、勋章或通知:
```json
{
"item": {
"range": { "timeRange": "30d", "startDate": "2026-06-01", "endDate": "2026-06-30" },
"summary": {
"total": 18,
"pendingBacklog": 4,
"handledInRange": 12,
"rewardPoints": 60,
"handledRate": 0.6667,
"avgHandleHours": 5.25
},
"byStatus": [],
"byType": [],
"byCategory": [],
"topQuestions": [],
"topHandlers": [],
"recentUnhandled": []
}
}
```
当前 Taro 租户营销中心已接 30 天反馈运营摘要。前端可以展示反馈积压、处理率、奖励积分、高优先级数量、高频题目和待处理反馈;不要在报表页直接改反馈状态,处理动作仍走 `POST /api/tenant-admin/feedbacks/status`
## 用户站内通知 ## 用户站内通知
站内通知用于承接反馈处理、反馈奖励、勋章发放、积分兑换完成或待履约等学生可见事件。它不是权限来源,也不替代订单、权益、积分或勋章接口的真实状态。 站内通知用于承接反馈处理、反馈奖励、勋章发放、积分兑换完成或待履约等学生可见事件。它不是权限来源,也不替代订单、权益、积分或勋章接口的真实状态。
@@ -3118,7 +3150,7 @@ GET /api/ai/school-recommendations/detail?reportId=<reportId>
- Banner/FAQ/公告/激活码/优惠券/积分任务/积分兑换,当前 Taro 租户营销中心已接 `GET/PUT /api/tenant-admin/coupons``GET /api/tenant-admin/coupons/redemptions``GET /api/tenant-admin/coupons/report``GET/PUT /api/tenant-admin/point-activity-tasks``GET /api/tenant-admin/point-activity-claims``GET/PUT /api/tenant-admin/point-exchange-items``GET /api/tenant-admin/point-exchange-orders`,用于配置复杂规则、查看核销明细、活动效果、任务领取和兑换记录。 - Banner/FAQ/公告/激活码/优惠券/积分任务/积分兑换,当前 Taro 租户营销中心已接 `GET/PUT /api/tenant-admin/coupons``GET /api/tenant-admin/coupons/redemptions``GET /api/tenant-admin/coupons/report``GET/PUT /api/tenant-admin/point-activity-tasks``GET /api/tenant-admin/point-activity-claims``GET/PUT /api/tenant-admin/point-exchange-items``GET /api/tenant-admin/point-exchange-orders`,用于配置复杂规则、查看核销明细、活动效果、任务领取和兑换记录。
- 勋章:`GET/PUT /api/tenant-admin/badges``GET/POST /api/tenant-admin/badge-grants` - 勋章:`GET/PUT /api/tenant-admin/badges``GET/POST /api/tenant-admin/badge-grants`
- 考试日期:`GET/PUT /api/tenant-admin/exam-dates` - 考试日期:`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 /api/tenant-admin/feedbacks``GET /api/tenant-admin/feedbacks/report``POST /api/tenant-admin/feedbacks/status``GET /api/tenant-admin/feedbacks/events`
- 销售/代理/CRM 队列、CRM 配置、CRM 跟进分配策略、分佣规则、成员分佣比例、分佣订单、结算单审核和线下打款登记;当前 Taro 租户营销中心已接 CRM、分佣、优惠券规则和核销报表第一版财务运营页已接退款、官方账单、对账异常、差错工单和调整凭证第一版。真实打款 provider、发票、生产账单抽样验收和更完整财务复核体验后续增强。 - 销售/代理/CRM 队列、CRM 配置、CRM 跟进分配策略、分佣规则、成员分佣比例、分佣订单、结算单审核和线下打款登记;当前 Taro 租户营销中心已接 CRM、分佣、优惠券规则和核销报表第一版财务运营页已接退款、官方账单、对账异常、差错工单和调整凭证第一版。真实打款 provider、发票、生产账单抽样验收和更完整财务复核体验后续增强。
CRM 分配策略由后端执行,前端只提交配置: CRM 分配策略由后端执行,前端只提交配置:

View File

@@ -7770,12 +7770,15 @@ async function testTenantAdminOps() {
assert.equal(fakeWechat.requests.at(-1)?.query?.js_code, 'integration-code-001', 'wechat code should be exchanged server-side'); assert.equal(fakeWechat.requests.at(-1)?.query?.js_code, 'integration-code-001', 'wechat code should be exchanged server-side');
assert.ok(!JSON.stringify(miniappLogin).includes('session-key-integration-code-001'), 'login response must not leak WeChat session_key'); assert.ok(!JSON.stringify(miniappLogin).includes('session-key-integration-code-001'), 'login response must not leak WeChat session_key');
assert.ok(!JSON.stringify(miniappLogin).includes('wechat-app-secret-smoke'), 'login response must not leak app secret'); assert.ok(!JSON.stringify(miniappLogin).includes('wechat-app-secret-smoke'), 'login response must not leak app secret');
assert.equal(miniappLogin.user?.avatarUrl, null, 'wechat miniapp login must not persist client avatarUrl');
assert.ok(!JSON.stringify(miniappLogin).includes('https://example.test/avatar.png'), 'wechat miniapp login response must not echo client avatarUrl');
const miniappMe = await request('/api/auth/me', { const miniappMe = await request('/api/auth/me', {
userId: false, userId: false,
headers: { authorization: `Bearer ${miniappLogin.session.token}` }, headers: { authorization: `Bearer ${miniappLogin.session.token}` },
}); });
assert.equal(miniappMe.user?.id, miniappLogin.user.id, 'wechat session should work with auth/me'); assert.equal(miniappMe.user?.id, miniappLogin.user.id, 'wechat session should work with auth/me');
assert.equal(miniappMe.user?.avatarUrl, null, 'wechat session auth/me should keep avatarUrl empty');
const wechatWebProvider = await request('/api/tenant-admin/auth-providers', { const wechatWebProvider = await request('/api/tenant-admin/auth-providers', {
userId: TENANT_ADMIN_USER_ID, userId: TENANT_ADMIN_USER_ID,
@@ -7814,6 +7817,7 @@ async function testTenantAdminOps() {
assert.ok(fakeWechat.requests.some(item => item.pathname === '/sns/userinfo'), 'wechat web login should fetch userinfo server-side'); assert.ok(fakeWechat.requests.some(item => item.pathname === '/sns/userinfo'), 'wechat web login should fetch userinfo server-side');
assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-web-token-web-code-001'), 'wechat web response must not leak access_token'); assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-web-token-web-code-001'), 'wechat web response must not leak access_token');
assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-app-secret-smoke'), 'wechat web response must not leak app secret'); assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-app-secret-smoke'), 'wechat web response must not leak app secret');
assert.ok(!JSON.stringify(wechatWebLogin).includes('wechat-web-avatar.png'), 'wechat web response must not echo provider avatar');
const qqProvider = await request('/api/tenant-admin/auth-providers', { const qqProvider = await request('/api/tenant-admin/auth-providers', {
userId: TENANT_ADMIN_USER_ID, userId: TENANT_ADMIN_USER_ID,
@@ -7854,6 +7858,8 @@ async function testTenantAdminOps() {
assert.ok(fakeQq.requests.some(item => item.pathname === '/user/get_user_info'), 'qq login should fetch userinfo server-side'); assert.ok(fakeQq.requests.some(item => item.pathname === '/user/get_user_info'), 'qq login should fetch userinfo server-side');
assert.ok(!JSON.stringify(qqLogin).includes('qq-token-qq-code-001'), 'qq response must not leak access_token'); assert.ok(!JSON.stringify(qqLogin).includes('qq-token-qq-code-001'), 'qq response must not leak access_token');
assert.ok(!JSON.stringify(qqLogin).includes('qq-client-secret-smoke'), 'qq response must not leak client secret'); assert.ok(!JSON.stringify(qqLogin).includes('qq-client-secret-smoke'), 'qq response must not leak client secret');
assert.equal(qqLogin.user?.avatarUrl, null, 'qq login must not persist provider avatarUrl');
assert.ok(!JSON.stringify(qqLogin).includes('qq-avatar.png'), 'qq login response must not echo provider avatar');
const paymentAccount = await request('/api/tenant-admin/payment-accounts', { const paymentAccount = await request('/api/tenant-admin/payment-accounts', {
userId: TENANT_ADMIN_USER_ID, userId: TENANT_ADMIN_USER_ID,
@@ -8283,6 +8289,26 @@ async function testTenantAdminOps() {
assert.ok(feedbackEvents.items?.some(item => item.toStatus === 'pending'), 'feedback events should include initial pending event'); assert.ok(feedbackEvents.items?.some(item => item.toStatus === 'pending'), 'feedback events should include initial pending event');
assert.ok(feedbackEvents.items?.some(item => item.toStatus === 'resolved'), 'feedback events should include resolved event'); assert.ok(feedbackEvents.items?.some(item => item.toStatus === 'resolved'), 'feedback events should include resolved event');
const feedbackReport = await request('/api/tenant-admin/feedbacks/report', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '30d', limit: 20 },
});
assert.ok(feedbackReport.item?.summary?.total >= 1, 'tenant admin feedback report should count created feedback');
assert.ok(feedbackReport.item?.summary?.handledInRange >= 1, 'tenant admin feedback report should count handled feedback');
assert.ok(feedbackReport.item?.summary?.rewardPoints >= 5, 'tenant admin feedback report should include feedback reward points');
assert.ok(
feedbackReport.item?.byStatus?.some(item => item.status === 'resolved'),
'tenant admin feedback report should group by resolved status',
);
assert.ok(Array.isArray(feedbackReport.item?.recentUnhandled), 'tenant admin feedback report should expose recent unhandled list');
const invalidFeedbackReportRange = await request('/api/tenant-admin/feedbacks/report', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '365d' },
expectStatus: 400,
});
assert.equal(invalidFeedbackReportRange.code, 'INVALID_FEEDBACK_REPORT_RANGE', 'feedback report should reject unsupported range');
const partnerFeedbackDenied = await request('/api/tenant-admin/feedbacks', { const partnerFeedbackDenied = await request('/api/tenant-admin/feedbacks', {
tenantId: PARTNER_TENANT_ID, tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID, userId: TENANT_ADMIN_USER_ID,
@@ -8290,6 +8316,13 @@ async function testTenantAdminOps() {
}); });
assert.equal(partnerFeedbackDenied.code, 'TENANT_ADMIN_REQUIRED', 'feedback admin must be tenant isolated'); assert.equal(partnerFeedbackDenied.code, 'TENANT_ADMIN_REQUIRED', 'feedback admin must be tenant isolated');
const partnerFeedbackReportDenied = await request('/api/tenant-admin/feedbacks/report', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
expectStatus: 403,
});
assert.equal(partnerFeedbackReportDenied.code, 'TENANT_ADMIN_REQUIRED', 'feedback report must be tenant isolated');
const batch = await request('/api/tenant-admin/code-batches', { const batch = await request('/api/tenant-admin/code-batches', {
userId: TENANT_ADMIN_USER_ID, userId: TENANT_ADMIN_USER_ID,
method: 'PUT', method: 'PUT',

View File

@@ -25,6 +25,7 @@ const importSecretValues = process.env.IMPORT_SECRET_VALUES === 'true';
const sensitiveKeyPattern = const sensitiveKeyPattern =
/(password|token|secret|privatekey|sessionkey|accesskey|appkey|apikey|api_v3_key|notifytoken|aeskey|openid|unionid|wxaccesstoken|wechatsessionkey|smscode|verifycode|verificationcode|captcha)/i; /(password|token|secret|privatekey|sessionkey|accesskey|appkey|apikey|api_v3_key|notifytoken|aeskey|openid|unionid|wxaccesstoken|wechatsessionkey|smscode|verifycode|verificationcode|captcha)/i;
const identityKeyPattern = /(phone|email|openid|unionid|sessionkey)/i; const identityKeyPattern = /(phone|email|openid|unionid|sessionkey)/i;
const externalAvatarKeyPattern = /(avatar|headimg|figureurl|profilephoto|portrait)/i;
const legacyLookupTables = new Set([ const legacyLookupTables = new Set([
'activation_codes', 'activation_codes',
@@ -109,7 +110,7 @@ function sanitizeRecord(record: JsonRecord): JsonRecord {
function stripSensitiveKeys(value: unknown, keyPath = ''): unknown { function stripSensitiveKeys(value: unknown, keyPath = ''): unknown {
const key = keyPath.split('.').pop() || keyPath; const key = keyPath.split('.').pop() || keyPath;
if (sensitiveKeyPattern.test(key)) return undefined; if (sensitiveKeyPattern.test(key) || externalAvatarKeyPattern.test(key)) return undefined;
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.map((item, index) => stripSensitiveKeys(item, `${keyPath}.${index}`)).filter(item => item !== undefined); return value.map((item, index) => stripSensitiveKeys(item, `${keyPath}.${index}`)).filter(item => item !== undefined);
} }
@@ -2293,7 +2294,7 @@ async function normalizeUsers(records: JsonRecord[]) {
text(r.email), text(r.email),
text(r.phone), text(r.phone),
text(r.name) || text(r.username), text(r.name) || text(r.username),
text(r.avatar), null,
normalizeTenantRole(r.role), normalizeTenantRole(r.role),
intValue(r.score), intValue(r.score),
dateText(r.lastSeenAt), dateText(r.lastSeenAt),

View File

@@ -0,0 +1,10 @@
create index if not exists idx_reports_tenant_created
on public.reports(tenant_id, created_at desc);
create index if not exists idx_reports_tenant_handled
on public.reports(tenant_id, handled_at desc, handled_by)
where handled_at is not null;
create index if not exists idx_score_events_tenant_feedback_reward
on public.user_score_events(tenant_id, created_at desc, source_id)
where event_type = 'feedback_reward' and source_type = 'reports';