forked from wangziqi/gongxue-base
feat: add feedback operations report
This commit is contained in:
@@ -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 => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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'>
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,支持班级范围权限和审计 |
|
||||||
|
|||||||
@@ -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 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 平台后台页面
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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` | 部分覆盖 | 已有刷题数、积分、背单词、模考最高分排行榜,支持租户/地区/班级范围和当前用户排名;租户默认关闭,学生端默认不请求;后续仅在明确开启活动并压测后补防刷、日/周榜预聚合、运营后台排名看板 |
|
||||||
|
|
||||||
## 租户后台功能
|
## 租户后台功能
|
||||||
|
|
||||||
|
|||||||
@@ -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 活跃度和激活码使用情况。
|
||||||
|
|||||||
@@ -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 分配策略由后端执行,前端只提交配置:
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
10
supabase/migrations/202606300011_feedback_report_indexes.sql
Normal file
10
supabase/migrations/202606300011_feedback_report_indexes.sql
Normal 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';
|
||||||
Reference in New Issue
Block a user