diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts index 56685d5f..fad252a8 100644 --- a/apps/api/src/features/auth/routes.ts +++ b/apps/api/src/features/auth/routes.ts @@ -675,7 +675,10 @@ function sanitizeOAuthProfilePayload(value: unknown): unknown { normalized.includes('refreshtoken') || normalized.includes('sessionkey') || normalized.includes('secret') || - normalized.includes('clientsecret') + normalized.includes('clientsecret') || + normalized.includes('avatar') || + normalized.includes('headimg') || + normalized.includes('figureurl') ) { continue; } @@ -764,16 +767,13 @@ async function callWechatWebUserInfo(input: { } const nickname = stringField(raw, ['nickname', 'name']); - const avatarUrl = stringField(raw, ['headimgurl', 'avatarUrl', 'avatar_url']); const unionId = stringField(raw, ['unionid', 'unionId']); return { nickname, - avatarUrl, unionId, profile: { ...jsonObject(sanitizeOAuthProfilePayload(raw)), nickname, - avatarUrl, }, }; } @@ -934,7 +934,6 @@ export async function wechatWebLoginRoute(ctx: RequestContext) { ...userInfo.profile, ...jsonObject(sanitizeOAuthProfilePayload(clientProfile)), nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']), - avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']), }; return transaction(async client => { @@ -1069,14 +1068,11 @@ async function callQqUserInfo(input: { } const nickname = stringField(raw, ['nickname', 'name']); - const avatarUrl = stringField(raw, ['figureurl_qq_2', 'figureurl_qq_1', 'figureurl_2', 'figureurl_1', 'avatarUrl']); return { nickname, - avatarUrl, profile: { ...jsonObject(sanitizeOAuthProfilePayload(raw)), nickname, - avatarUrl, }, }; } @@ -1135,7 +1131,6 @@ export async function qqLoginRoute(ctx: RequestContext) { ...userInfo.profile, ...jsonObject(sanitizeOAuthProfilePayload(clientProfile)), nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']), - avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']), }; return transaction(async client => { diff --git a/apps/api/src/features/auth/service.ts b/apps/api/src/features/auth/service.ts index 8c77411a..f718e486 100644 --- a/apps/api/src/features/auth/service.ts +++ b/apps/api/src/features/auth/service.ts @@ -56,6 +56,36 @@ export function hashSmsCode(tenantId: string, phone: string, purpose: string, co .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 = {}; + for (const [key, child] of Object.entries(value as Record)) { + 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 | undefined) { + const sanitized = stripUnsupportedOAuthProfileFields(profile || {}); + return sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized) + ? sanitized as Record + : {}; +} + export function createSessionToken() { return `tk_${crypto.randomBytes(32).toString('base64url')}`; } @@ -300,7 +330,7 @@ export async function upsertOAuthUser( return { user: matchedUser, isNewUser: false }; } - const profile = input.profile || {}; + const profile = publicOAuthProfile(input.profile); const nickname = typeof profile.nickname === 'string' ? profile.nickname @@ -309,17 +339,11 @@ export async function upsertOAuthUser( : typeof profile.name === 'string' ? profile.name : null; - const avatarUrl = - typeof profile.avatarUrl === 'string' - ? profile.avatarUrl - : typeof profile.avatar_url === 'string' - ? profile.avatar_url - : null; const userResult = await client.query( ` - insert into public.platform_users (username, phone, email, name, avatar_url, primary_role, raw_profile) - values ($1, $2, $3::citext, $4, $5, 'student', $6::jsonb) + insert into public.platform_users (username, phone, email, name, primary_role, raw_profile) + values ($1, $2, $3::citext, $4, 'student', $5::jsonb) returning id, username, phone, name, avatar_url as "avatarUrl", primary_role as "primaryRole", created_at as "createdAt" `, @@ -328,7 +352,6 @@ export async function upsertOAuthUser( input.phone || null, input.email || null, nickname, - avatarUrl, JSON.stringify({ source: input.provider, profile, diff --git a/apps/api/src/features/tenant-admin/index.ts b/apps/api/src/features/tenant-admin/index.ts index 715a3f0b..674de5f0 100644 --- a/apps/api/src/features/tenant-admin/index.ts +++ b/apps/api/src/features/tenant-admin/index.ts @@ -22,6 +22,7 @@ import { tenantUserNotificationsRoute } from './notifications.js'; import { tenantExamDatesRoute, tenantFeedbackEventsRoute, + tenantFeedbackReportRoute, tenantFeedbacksRoute, updateTenantFeedbackStatusRoute, upsertTenantExamDateRoute, @@ -131,6 +132,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [ ['GET', '/api/tenant-admin/exam-dates', tenantExamDatesRoute], ['PUT', '/api/tenant-admin/exam-dates', upsertTenantExamDateRoute], ['GET', '/api/tenant-admin/feedbacks', tenantFeedbacksRoute], + ['GET', '/api/tenant-admin/feedbacks/report', tenantFeedbackReportRoute], ['POST', '/api/tenant-admin/feedbacks/status', updateTenantFeedbackStatusRoute], ['GET', '/api/tenant-admin/feedbacks/events', tenantFeedbackEventsRoute], ['GET', '/api/tenant-admin/code-batches', codeBatchesRoute], diff --git a/apps/api/src/features/tenant-admin/operations.ts b/apps/api/src/features/tenant-admin/operations.ts index 170f31c4..03657444 100644 --- a/apps/api/src/features/tenant-admin/operations.ts +++ b/apps/api/src/features/tenant-admin/operations.ts @@ -14,6 +14,11 @@ type JsonBody = Record; const REPORT_STATUSES = ['pending', 'accepted', 'rejected', 'resolved', 'closed']; const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent']; +const FEEDBACK_REPORT_RANGES: Record = { + '7d': 7, + '30d': 30, + '90d': 90, +}; const REPORT_STATUS_LABELS: Record = { pending: '待处理', accepted: '已受理', @@ -35,10 +40,71 @@ function intValue(value: unknown, fallback: number) { 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) { 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) { if (status === 'resolved') return 'success'; if (status === 'rejected') return 'warning'; @@ -246,6 +312,307 @@ export async function tenantFeedbacksRoute(ctx: RequestContext) { 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>( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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) { const auth = await requireTenantAdmin(ctx); requireTenantPermission(auth, 'feedback:read'); diff --git a/apps/taro/src/pages/tenant-admin/marketing/index.tsx b/apps/taro/src/pages/tenant-admin/marketing/index.tsx index 0cdc9248..834efbd4 100644 --- a/apps/taro/src/pages/tenant-admin/marketing/index.tsx +++ b/apps/taro/src/pages/tenant-admin/marketing/index.tsx @@ -17,6 +17,7 @@ import { loadCoupons, loadCrmConfig, loadCrmQueue, + loadFeedbackReport, loadPointActivityClaims, loadPointActivityTasks, loadPointExchangeItems, @@ -43,6 +44,7 @@ import { type CouponReport, type CrmConfigItem, type CrmQueueItem, + type FeedbackReport, type PointActivityTaskItem, type PointExchangeItem, type PointsRiskReport, @@ -307,6 +309,7 @@ export default function TenantMarketingPage() { const [pointExchangeItems, setPointExchangeItems] = useState([]); const [pointExchangeOrders, setPointExchangeOrders] = useState[]>([]); const [pointsRiskReport, setPointsRiskReport] = useState(null); + const [feedbackReport, setFeedbackReport] = useState(null); const [crmForm, setCrmForm] = useState({ enabled: false, url: '', @@ -378,6 +381,7 @@ export default function TenantMarketingPage() { pointExchangePayload, pointExchangeOrderPayload, pointsRiskPayload, + feedbackReportPayload, ] = await Promise.all([ loadCoupons({ status: couponFilter.status || undefined, campaignName: couponFilter.campaignName || undefined }).catch(() => ({ items: [] })), loadCouponReport({ @@ -411,6 +415,7 @@ export default function TenantMarketingPage() { limit: 30, }).catch(() => ({ items: [] })), loadPointsRiskReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })), + loadFeedbackReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })), ]); const nextCrm = crmConfigPayload.item || null; const nextSettings = commissionSettingsPayload.item || null; @@ -433,6 +438,7 @@ export default function TenantMarketingPage() { setPointExchangeItems(pointExchangePayload.items || []); setPointExchangeOrders(pointExchangeOrderPayload.items || []); setPointsRiskReport(pointsRiskPayload.item || null); + setFeedbackReport(feedbackReportPayload.item || null); if (nextCrm) { setCrmForm({ enabled: nextCrm.enabled === true, @@ -610,7 +616,7 @@ export default function TenantMarketingPage() { setBusy('points'); setError(''); 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 }), loadPointActivityClaims({ taskId: nextFilter.selectedTaskId || undefined, @@ -625,12 +631,14 @@ export default function TenantMarketingPage() { limit: 50, }).catch(() => ({ items: [] })), loadPointsRiskReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })), + loadFeedbackReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })), ]); setPointTasks(taskPayload.items || []); setPointClaims(claimPayload.items || []); setPointExchangeItems(exchangePayload.items || []); setPointExchangeOrders(orderPayload.items || []); setPointsRiskReport(riskPayload.item || null); + setFeedbackReport(feedbackReportPayload.item || null); } catch (nextError) { setError(nextError instanceof Error ? nextError.message : '积分运营数据加载失败'); } finally { @@ -980,6 +988,39 @@ export default function TenantMarketingPage() { 预计佣金{money(commission?.commissionAmountCents)} + + 反馈运营摘要 + + 30 天反馈{String(feedbackReport?.summary?.total || 0)} + 待处理积压{String(feedbackReport?.summary?.pendingBacklog || 0)} + 处理率{percentLabel(feedbackReport?.summary?.handledRate)} + 奖励积分{String(feedbackReport?.summary?.rewardPoints || 0)} + + + 高优先级{String(feedbackReport?.summary?.highPriority || 0)} + 已解决{String((feedbackReport?.summary?.resolved || 0) + (feedbackReport?.summary?.closed || 0))} + 平均处理{String(feedbackReport?.summary?.avgHandleHours || 0)}h + 采纳率{percentLabel(feedbackReport?.summary?.resolutionRate)} + + + {(feedbackReport?.recentUnhandled || []).slice(0, 4).map((item, index) => ( + + {String(item.title || item.type || '待处理反馈')} · {String(item.priority || 'normal')} + {String(item.userName || item.userPhone || item.userId || '学生')} · {String(item.status || '-')} · 已等待 {String(item.ageHours || 0)}h + + ))} + {(feedbackReport?.topQuestions || []).slice(0, 4).map((item, index) => ( + + {String(item.questionPreview || item.questionId || '高频反馈题目')} + {String(item.questionType || '题目')} · 反馈 {String(item.reportCount || 0)} 次 · 高优先级 {String(item.highPriority || 0)} + + ))} + + {!(feedbackReport?.recentUnhandled?.length || feedbackReport?.topQuestions?.length) ? ( + 当前 30 天暂无反馈运营风险项,或当前角色没有 `feedback:read` 权限。 + ) : null} + + 积分任务与兑换 diff --git a/apps/taro/src/services/tenantAdmin.ts b/apps/taro/src/services/tenantAdmin.ts index e2afc7ab..c218fdc2 100644 --- a/apps/taro/src/services/tenantAdmin.ts +++ b/apps/taro/src/services/tenantAdmin.ts @@ -606,6 +606,40 @@ export interface PointsRiskReport { dailyTrend?: Array>; } +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>; + byType?: Array>; + byCategory?: Array>; + byPriority?: Array>; + dailyTrend?: Array>; + topQuestions?: Array>; + topHandlers?: Array>; + recentUnhandled?: Array>; +} + export interface CodeBatchItem { id: string; 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() { return apiRequest<{ items?: CodeBatchItem[] }>('/api/tenant-admin/code-batches'); } diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 874ec12a..b84626f1 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -64,10 +64,10 @@ | 模考交卷报告 | 可联调 | `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/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/PATCH /api/profile/me`;仅支持 `avatarPreset=male/female` 和系统默认头像路径,后端拒绝直接 `PATCH avatarUrl`,不提供用户头像上传 | -| 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转,处理结果和奖励积分会写入用户站内通知 | +| 学生预设头像 | 可联调 | `GET/PATCH /api/profile/me`;仅支持 `avatarPreset=male/female` 和系统默认头像路径,后端拒绝直接 `PATCH avatarUrl`,不提供用户头像上传;微信/QQ 登录和 PocketBase 导入脚本均不会把第三方头像 URL 写入学生资料 | +| 题目反馈/纠错 | 可联调 | `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` 规则勋章自动发放,重复签到不重复发放 | | 积分活动/兑换 | 可联调 | `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`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态;签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩规则已支持自动发放,自动/手动获得勋章会写入用户站内通知 | @@ -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/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/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/members`、`permissions`、`role-templates`、`audit-logs` | | 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index e6a700da..a9c975e3 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -10,7 +10,7 @@ - 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费、逾期催缴和用量。 - 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。 -- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、男女预设头像、站内通知、勋章、排行榜接口(租户默认关闭)、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。 +- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、男女预设头像、站内通知、勋章、排行榜接口(租户默认关闭)、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知;租户后台已具备反馈运营聚合报表和积分风控只读报表。 - 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。 - 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index ba8658e5..abfe8824 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -15,7 +15,7 @@ - `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。 - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。 - `platform-admin`:平台方租户管理、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/告警状态流、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、使用量。 - - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 + - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理和运营报表、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。 - `tenant`:域名/租户解析。 - 鉴权上下文已支持 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_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理已落库:`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 天周期,以及租户/地区/班级范围。 - `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 @@ -244,6 +244,7 @@ POST /api/tenant-admin/badge-grants GET /api/tenant-admin/exam-dates PUT /api/tenant-admin/exam-dates GET /api/tenant-admin/feedbacks +GET /api/tenant-admin/feedbacks/report POST /api/tenant-admin/feedbacks/status GET /api/tenant-admin/feedbacks/events GET /api/tenant-admin/code-batches diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 6dbb8f31..166c213a 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -46,7 +46,7 @@ - 上线前需要把三套 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:`/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` 做用户通知查看。通知只做展示、跳转和已读/归档状态,业务权限和权益仍以后端源接口为准。 ## 不能误认为已商用完成的部分 @@ -96,11 +96,11 @@ | 数据看板 | `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/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/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 平台后台页面 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 9dd4b1d3..2a8d43ee 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -256,6 +256,7 @@ tenant-admin: GET /api/tenant-admin/exam-dates PUT /api/tenant-admin/exam-dates GET /api/tenant-admin/feedbacks + GET /api/tenant-admin/feedbacks/report POST /api/tenant-admin/feedbacks/status GET /api/tenant-admin/feedbacks/events GET /api/tenant-admin/code-batches diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index cd30e54f..c1390142 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -31,12 +31,12 @@ | 知识手册 | `Handbook*.tsx` | 已覆盖 | Taro 已接手册科目/章节/条目、RichContent 安全渲染、H5 KaTeX 公式渲染和私有资源 ID 短签名地基第一版;后续补搜索、高亮、目录锚点、小程序公式真机验收和图片资源字段化 | | 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 | | 商城/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 级水印和生命周期策略 | | AI 择校推荐 | 业务规划新增 | 部分覆盖 | 已有 SVIP 门禁、学生输入 schema、地区/分数线上下文、稳定 JSON 输出、报告台账、审计和 Taro 学生端基础页;真实 AI provider、prompt 版本管理后台、报告 PDF 渲染和更细推荐算法待补 | -| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分和站内通知已覆盖;Taro 个人中心消息中心可查看反馈处理/奖励通知;仍缺外部订阅消息/短信提醒、批量统计和内容修复闭环 | -| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等、签到/积分阈值自动发放勋章、积分活动任务、积分兑换商品、优惠券兑换履约、租户后台配置和积分风控只读报表已覆盖;后续补活动效果看板和更多系统触发任务 | -| 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 部分覆盖 | 已有刷题数、积分、背单词、模考最高分排行榜,支持租户/地区/班级范围和当前用户排名;后续补防刷、日/周榜预聚合、运营后台排名看板 | +| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分、站内通知和租户反馈运营聚合报表已覆盖;Taro 个人中心消息中心可查看反馈处理/奖励通知,租户营销中心已接 30 天反馈运营摘要;仍缺外部订阅消息/短信提醒和内容修复闭环 | +| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等、签到/积分阈值自动发放勋章、积分活动任务、积分兑换商品、优惠券兑换履约、租户后台配置和积分风控只读报表已覆盖;学生激励默认以勋章自动发放为主,排行榜不默认开启;后续补更细活动效果看板和更多系统触发任务 | +| 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 部分覆盖 | 已有刷题数、积分、背单词、模考最高分排行榜,支持租户/地区/班级范围和当前用户排名;租户默认关闭,学生端默认不请求;后续仅在明确开启活动并压测后补防刷、日/周榜预聚合、运营后台排名看板 | ## 租户后台功能 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 23a82e17..f8cb4cc4 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -21,9 +21,9 @@ - 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。 - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。 -- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。 -- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。学生激励以后台配置勋章自动发放为主,不默认启用排行榜竞争。后续补活动效果看板和更细前端运营配置体验。 -- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;兑换完成或待履约会写入用户站内通知;Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台和积分风控只读摘要第一版。后续补更细活动效果看板。 +- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、反馈运营聚合报表、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。 +- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。学生激励以后台配置勋章自动发放为主,不默认启用排行榜竞争。后续补更细活动效果看板和更细前端运营配置体验。 +- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;兑换完成或待履约会写入用户站内通知;Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台、反馈运营摘要和积分风控只读摘要第一版。后续补更细活动效果看板。 - 用户站内通知第一版已完成:反馈处理、反馈奖励、勋章发放、积分兑换会创建 `user_notifications`;学生端可查询/标记状态,租户后台具备 `notifications:read` 权限的成员可查看租户内通知;Taro 学生个人中心已接消息筛选、已读和归档第一版,租户营销中心已接用户通知查看和筛选第一版。后续补独立消息中心增强、外部微信订阅消息/短信和批量统计。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 - 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。 @@ -145,7 +145,7 @@ 9. 积分和反馈增强 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 - 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩自动发放。 - - 积分兑换、活动任务、优惠券兑换履约、积分风控只读报表和站内通知后端第一阶段已完成;Taro 学生个人中心和租户营销中心已接积分任务/兑换第一版,租户营销中心已接 30 天风控摘要;继续补独立消息中心增强、外部订阅消息/短信、反馈聚合统计和更细活动效果看板。 + - 积分兑换、活动任务、优惠券兑换履约、积分风控只读报表、反馈运营聚合报表和站内通知后端第一阶段已完成;Taro 学生个人中心和租户营销中心已接积分任务/兑换第一版,租户营销中心已接 30 天风控摘要和反馈运营摘要;继续补独立消息中心增强、外部订阅消息/短信和更细活动效果看板。 10. 数据看板 - 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 4fbaa940..461e3e3d 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -253,7 +253,7 @@ tenant::theme | 错题复习 | `GET /api/learning/wrong-questions/review-plan`、`POST /api/learning/practice-sessions` with `mode=wrong_review` | | 收藏夹 | `GET/POST /api/learning/favorites/questions` | | 练习历史/统计 | `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` | | 题目反馈 | `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` | @@ -277,7 +277,7 @@ tenant::theme | 学生跟进任务 | `GET/PUT /api/tenant-admin/students/followups` | | 租户教师 | `GET /api/tenant-admin/teachers` | | 租户考试日期 | `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/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` | @@ -1125,7 +1125,7 @@ GET /api/tenant-content/assets/security-scan-events?assetId=&limit=100 ## 个人中心预设头像 -学生端不支持用户上传头像。系统只提供男女两种默认头像预设,避免头像上传带来的存储、内容安全、隐私和审核复杂度。前端不要展示文件选择器,不要调用对象存储上传链路,也不要把微信/QQ 返回的头像 URL 写入学生资料。 +学生端不支持用户上传头像。系统只提供男女两种默认头像预设,避免头像上传带来的存储、内容安全、隐私和审核复杂度。前端不要展示文件选择器,不要调用对象存储上传链路,也不要把微信/QQ 返回的头像 URL 写入学生资料。后端登录链路和 PocketBase 导入脚本也会丢弃第三方头像字段,学生展示头像必须来自 `avatarPreset`。 当前 Taro service 使用: @@ -1347,6 +1347,38 @@ GET /api/tenant-admin/points-risk-report?timeRange=30d&limit=20 - 反馈状态由租户后台处理,学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。 - 租户后台处理反馈时,奖励积分由后端 `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= - 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/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 分配策略由后端执行,前端只提交配置: diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 05cb5236..b8766c6c 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -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.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.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', { userId: false, 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?.avatarUrl, null, 'wechat session auth/me should keep avatarUrl empty'); const wechatWebProvider = await request('/api/tenant-admin/auth-providers', { 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(!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-web-avatar.png'), 'wechat web response must not echo provider avatar'); const qqProvider = await request('/api/tenant-admin/auth-providers', { 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(!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.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', { 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 === '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', { tenantId: PARTNER_TENANT_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'); + 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', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', diff --git a/scripts/import-pocketbase/src/import-json.ts b/scripts/import-pocketbase/src/import-json.ts index 3de70100..6999240b 100644 --- a/scripts/import-pocketbase/src/import-json.ts +++ b/scripts/import-pocketbase/src/import-json.ts @@ -25,6 +25,7 @@ const importSecretValues = process.env.IMPORT_SECRET_VALUES === 'true'; const sensitiveKeyPattern = /(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 externalAvatarKeyPattern = /(avatar|headimg|figureurl|profilephoto|portrait)/i; const legacyLookupTables = new Set([ 'activation_codes', @@ -109,7 +110,7 @@ function sanitizeRecord(record: JsonRecord): JsonRecord { function stripSensitiveKeys(value: unknown, keyPath = ''): unknown { 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)) { 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.phone), text(r.name) || text(r.username), - text(r.avatar), + null, normalizeTenantRole(r.role), intValue(r.score), dateText(r.lastSeenAt), diff --git a/supabase/migrations/202606300011_feedback_report_indexes.sql b/supabase/migrations/202606300011_feedback_report_indexes.sql new file mode 100644 index 00000000..0eb69bcd --- /dev/null +++ b/supabase/migrations/202606300011_feedback_report_indexes.sql @@ -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';