feat: add student followup report

This commit is contained in:
Codex
2026-06-30 16:42:23 +08:00
parent 7f97b52165
commit 7473c7a6d7
13 changed files with 567 additions and 22 deletions

View File

@@ -12,7 +12,7 @@
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
- `apps/api` 独立业务 API后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、学生批量 CRM 推送、审计日志。
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、跟进效果统计、学生批量 CRM 推送、审计日志。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口租户默认关闭、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。
- 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。
@@ -34,8 +34,8 @@
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载、字段映射 API 和 Taro 租户内容页第一版导入操作台已完成。
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决积分活动任务自动发放已可联调积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘H5 端已用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权、平台员工管理,以及创建租户、租户详情、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、权限点勾选、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。
- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决积分活动任务、练习次数、单词掌握和模考成绩系统触发勋章已可联调积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。学生激励默认以后台配置勋章自动发放为主,排行榜默认不开启也不在学生端默认请求。后续还要补更细活动效果看板、外部微信订阅消息/短信推送、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘H5 端已用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版个人中心已接学习报告、14 天趋势、题型表现、最近练习、积分任务/兑换/积分明细和消息中心第一版;学生端不默认请求排行榜,仅在租户显式开启 `enableLeaderboard` 并完成压测后进入独立排行榜页或活动页;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,学生运营页已接跟进看板和批量 CRM 推送第一版,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权、平台员工管理,以及创建租户、租户详情、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、权限点勾选、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
更完整的进度看这些文档:

View File

@@ -21,6 +21,7 @@ const STUDENT_NOTE_VISIBILITIES = ['tenant_staff', 'class_staff', 'author_only']
const STUDENT_FOLLOWUP_TYPES = ['learning', 'service', 'sales', 'renewal', 'risk', 'custom'];
const STUDENT_FOLLOWUP_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
const STUDENT_FOLLOWUP_STATUSES = ['open', 'in_progress', 'done', 'cancelled'];
const FOLLOWUP_REPORT_RANGES: Record<string, number> = { '7d': 7, '30d': 30, '90d': 90 };
const MAX_BULK_STUDENTS = 200;
const MAX_BULK_CLASS_ASSIGNMENTS = 500;
const MAX_BULK_CRM_PUSH = 100;
@@ -28,6 +29,14 @@ const CRM_ASSIGNABLE_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator',
const STUDENT_AVATAR_FIELD_KEYS = ['avatarUrl', 'avatar_url', 'avatar', 'headimgurl', 'headImgUrl', 'figureurl', 'figureurl_qq_1', 'figureurl_qq_2'];
const STUDENT_PRIMARY_ROLE_FIELD_KEYS = ['primaryRole', 'primary_role'];
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const shanghaiDateFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -85,6 +94,16 @@ function intValue(value: unknown, fallback: number) {
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
}
function numberValue(value: unknown, fallback = 0) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) ? parsed : fallback;
}
function ratio(numerator: number, denominator: number) {
if (denominator <= 0) return 0;
return Number((numerator / denominator).toFixed(4));
}
function boolValue(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
@@ -139,6 +158,69 @@ function optionalTimestampString(value: unknown, fieldName: string) {
return new Date(time).toISOString();
}
function shanghaiDateKey(date: Date) {
const parts = Object.fromEntries(
shanghaiDateFormatter.formatToParts(date).map(part => [part.type, part.value]),
);
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 isValidShanghaiDateKey(dateKey: string) {
if (!DATE_KEY_PATTERN.test(dateKey)) return false;
const date = new Date(`${dateKey}T00:00:00+08:00`);
return Number.isFinite(date.getTime()) && shanghaiDateKey(date) === dateKey;
}
function daysBetweenInclusive(startDate: string, endDate: string) {
const start = Date.parse(`${startDate}T00:00:00+08:00`);
const end = Date.parse(`${endDate}T00:00:00+08:00`);
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0;
return Math.floor((end - start) / 86_400_000) + 1;
}
function parseFollowupReportRange(timeRangeValue: string, startDateValue: string, endDateValue: string) {
if (startDateValue || endDateValue) {
if (!isValidShanghaiDateKey(startDateValue) || !isValidShanghaiDateKey(endDateValue)) {
throw new HttpError(400, 'startDate and endDate must use YYYY-MM-DD', 'INVALID_DATE_RANGE');
}
const days = daysBetweenInclusive(startDateValue, endDateValue);
if (days <= 0) throw new HttpError(400, 'endDate must be greater than or equal to startDate', 'INVALID_DATE_RANGE');
if (days > 180) throw new HttpError(400, 'Follow-up report range supports at most 180 days', 'DATE_RANGE_TOO_LARGE');
return {
timeRange: 'custom',
days,
startDate: startDateValue,
endDate: endDateValue,
startAt: shanghaiDayStartIso(startDateValue),
endAt: shanghaiDayStartIso(addDaysKey(endDateValue, 1)),
};
}
const timeRange = timeRangeValue || '30d';
const days = FOLLOWUP_REPORT_RANGES[timeRange];
if (!days) throw new HttpError(400, 'Unsupported follow-up report time range', 'INVALID_FOLLOWUP_REPORT_RANGE');
const endDate = shanghaiDateKey(new Date());
const startDate = addDaysKey(endDate, -(days - 1));
return {
timeRange,
days,
startDate,
endDate,
startAt: shanghaiDayStartIso(startDate),
endAt: shanghaiDayStartIso(addDaysKey(endDate, 1)),
};
}
function classCodeValue(value: unknown) {
const code = nullableString(value);
if (!code) return null;
@@ -1372,6 +1454,317 @@ export async function tenantStudentFollowupsRoute(ctx: RequestContext) {
return { items: items.map(item => maskStudentFields(auth, item)) };
}
export async function tenantStudentFollowupReportRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'students:followups:read');
const range = parseFollowupReportRange(
stringParam(ctx, 'timeRange'),
stringParam(ctx, 'startDate'),
stringParam(ctx, 'endDate'),
);
const limit = intParam(ctx, 'limit', 10, 50);
const assignedTo = stringParam(ctx, 'assignedToUserId');
const followupType = stringParam(ctx, 'followupType');
const classId = stringParam(ctx, 'classId');
const scopedIds = await scopedClassIds(auth);
if (classId) await ensureReadableClass(auth, classId);
if (followupType && !STUDENT_FOLLOWUP_TYPES.includes(followupType)) {
throw new HttpError(400, `Invalid follow-up type: ${followupType}`, 'INVALID_FOLLOWUP_TYPE');
}
const params: unknown[] = [auth.tenantId, range.startAt, range.endAt];
const filters = ['sf.tenant_id = $1', '$2::timestamptz <= $3::timestamptz'];
if (assignedTo) {
params.push(assignedTo);
filters.push(`sf.assigned_to_user_id = $${params.length}::uuid`);
}
if (followupType) {
params.push(followupType);
filters.push(`sf.followup_type = $${params.length}`);
}
if (classId) {
params.push(classId);
filters.push(`sf.class_id = $${params.length}::uuid`);
}
if (scopedIds) {
params.push(scopedIds);
filters.push(`exists (
select 1 from public.tenant_class_members scoped_cm
where scoped_cm.tenant_id = sf.tenant_id
and scoped_cm.user_id = sf.student_user_id
and scoped_cm.class_id = any($${params.length}::uuid[])
and scoped_cm.member_type = 'student'
and scoped_cm.status = 'active'
)`);
}
const whereSql = filters.join(' and ');
const limitedParams = [...params, limit];
const [
summary,
byStatus,
byType,
byPriority,
byAssignee,
byClass,
dailyTrend,
overdueItems,
crmQueueSummary,
] = await Promise.all([
query<Record<string, unknown>>(
`
select
count(*) filter (where sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as total,
count(*) filter (where sf.status = 'open' and sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as open,
count(*) filter (where sf.status = 'in_progress' and sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as "inProgress",
count(*) filter (where sf.status = 'done' and sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as done,
count(*) filter (where sf.status = 'cancelled' and sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as cancelled,
count(*) filter (where sf.priority in ('high', 'urgent') and sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as "highPriority",
count(*) filter (where sf.metadata->'crmPush' is not null and sf.created_at >= $2::timestamptz and sf.created_at < $3::timestamptz)::int as "crmPushTasks",
count(*) filter (where sf.due_at is not null and sf.due_at < now() and sf.status in ('open', 'in_progress'))::int as "overdueBacklog",
count(*) filter (where sf.status in ('open', 'in_progress'))::int as "openBacklog",
count(*) filter (where sf.completed_at >= $2::timestamptz and sf.completed_at < $3::timestamptz)::int as "completedInRange",
coalesce(avg(extract(epoch from (sf.completed_at - sf.created_at)) / 3600) filter (
where sf.completed_at >= $2::timestamptz and sf.completed_at < $3::timestamptz and sf.completed_at is not null
), 0)::numeric as "avgCompleteHours"
from public.tenant_student_followups sf
where ${whereSql}
`,
params,
),
query<Record<string, unknown>>(
`
select sf.status, count(*)::int as count
from public.tenant_student_followups sf
where ${whereSql}
and sf.created_at >= $2::timestamptz
and sf.created_at < $3::timestamptz
group by sf.status
order by case sf.status
when 'open' then 1
when 'in_progress' then 2
when 'done' then 3
when 'cancelled' then 4
else 9
end
`,
params,
),
query<Record<string, unknown>>(
`
select sf.followup_type as "followupType", count(*)::int as count
from public.tenant_student_followups sf
where ${whereSql}
and sf.created_at >= $2::timestamptz
and sf.created_at < $3::timestamptz
group by sf.followup_type
order by count desc, "followupType" asc
`,
params,
),
query<Record<string, unknown>>(
`
select sf.priority, count(*)::int as count
from public.tenant_student_followups sf
where ${whereSql}
and sf.created_at >= $2::timestamptz
and sf.created_at < $3::timestamptz
group by sf.priority
order by case sf.priority
when 'urgent' then 1
when 'high' then 2
when 'normal' then 3
when 'low' then 4
else 9
end
`,
params,
),
query<Record<string, unknown>>(
`
select sf.assigned_to_user_id as "assignedToUserId",
coalesce(assignee.name, assignee.username, '未指派') as "assignedToName",
tm.role as "assignedToRole",
count(*)::int as total,
count(*) filter (where sf.status in ('open', 'in_progress'))::int as open,
count(*) filter (where sf.status = 'done')::int as done,
count(*) filter (where sf.due_at is not null and sf.due_at < now() and sf.status in ('open', 'in_progress'))::int as overdue,
count(*) filter (where sf.metadata->'crmPush' is not null)::int as "crmPushTasks",
coalesce(avg(extract(epoch from (sf.completed_at - sf.created_at)) / 3600) filter (where sf.completed_at is not null), 0)::numeric as "avgCompleteHours",
max(coalesce(sf.completed_at, sf.updated_at, sf.created_at)) as "latestActivityAt"
from public.tenant_student_followups sf
left join public.platform_users assignee on assignee.id = sf.assigned_to_user_id
left join public.tenant_memberships tm on tm.tenant_id = sf.tenant_id
and tm.user_id = sf.assigned_to_user_id
and tm.status = 'active'
and tm.role = any($${limitedParams.length + 1}::text[])
where ${whereSql}
and sf.created_at >= $2::timestamptz
and sf.created_at < $3::timestamptz
group by sf.assigned_to_user_id, assignee.name, assignee.username, tm.role
order by total desc, overdue desc, "latestActivityAt" desc
limit $${limitedParams.length}
`,
[...limitedParams, CRM_ASSIGNABLE_ROLES],
),
query<Record<string, unknown>>(
`
select sf.class_id as "classId",
coalesce(tc.name, '未绑定班级') as "className",
tc.code as "classCode",
count(*)::int as total,
count(*) filter (where sf.status in ('open', 'in_progress'))::int as open,
count(*) filter (where sf.status = 'done')::int as done,
count(*) filter (where sf.due_at is not null and sf.due_at < now() and sf.status in ('open', 'in_progress'))::int as overdue,
max(coalesce(sf.completed_at, sf.updated_at, sf.created_at)) as "latestActivityAt"
from public.tenant_student_followups sf
left join public.tenant_classes tc on tc.tenant_id = sf.tenant_id and tc.id = sf.class_id
where ${whereSql}
and sf.created_at >= $2::timestamptz
and sf.created_at < $3::timestamptz
group by sf.class_id, tc.name, tc.code
order by total desc, overdue desc, "latestActivityAt" desc
limit $${limitedParams.length}
`,
limitedParams,
),
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 (sf.created_at at time zone 'Asia/Shanghai')::date::text as date,
count(*)::int as created,
count(*) filter (where sf.metadata->'crmPush' is not null)::int as "crmPushCreated",
count(*) filter (where sf.priority in ('high', 'urgent'))::int as "highPriority"
from public.tenant_student_followups sf
where ${whereSql}
and sf.created_at >= $2::timestamptz
and sf.created_at < $3::timestamptz
group by (sf.created_at at time zone 'Asia/Shanghai')::date
),
completed_daily as (
select (sf.completed_at at time zone 'Asia/Shanghai')::date::text as date,
count(*)::int as completed
from public.tenant_student_followups sf
where ${whereSql}
and sf.completed_at >= $2::timestamptz
and sf.completed_at < $3::timestamptz
and sf.status = 'done'
group by (sf.completed_at at time zone 'Asia/Shanghai')::date
)
select (d.day_start at time zone 'Asia/Shanghai')::date::text as date,
coalesce(cd.created, 0)::int as created,
coalesce(cd."crmPushCreated", 0)::int as "crmPushCreated",
coalesce(cd."highPriority", 0)::int as "highPriority",
coalesce(done.completed, 0)::int as completed
from days d
left join created_daily cd on cd.date = (d.day_start at time zone 'Asia/Shanghai')::date::text
left join completed_daily done on done.date = (d.day_start at time zone 'Asia/Shanghai')::date::text
order by date asc
`,
params,
),
query<Record<string, unknown>>(
`
select sf.id, sf.student_user_id as "studentUserId",
student.name as "studentName", student.phone as "studentPhone",
sf.assigned_to_user_id as "assignedToUserId",
coalesce(assignee.name, assignee.username) as "assignedToName",
sf.class_id as "classId", tc.name as "className",
sf.title, sf.followup_type as "followupType", sf.priority,
sf.status, sf.due_at as "dueAt",
extract(epoch from (now() - sf.due_at)) / 3600 as "overdueHours",
sf.created_at as "createdAt", sf.updated_at as "updatedAt"
from public.tenant_student_followups sf
left join public.platform_users student on student.id = sf.student_user_id
left join public.platform_users assignee on assignee.id = sf.assigned_to_user_id
left join public.tenant_classes tc on tc.tenant_id = sf.tenant_id and tc.id = sf.class_id
where ${whereSql}
and sf.due_at is not null
and sf.due_at < now()
and sf.status in ('open', 'in_progress')
order by case sf.priority
when 'urgent' then 1
when 'high' then 2
when 'normal' then 3
else 4
end, sf.due_at asc
limit $${limitedParams.length}
`,
limitedParams,
),
query<Record<string, unknown>>(
`
select
count(*) filter (where q.created_at >= $2::timestamptz and q.created_at < $3::timestamptz)::int as total,
count(*) filter (where q.status in ('pending', 'retrying', 'processing') and q.created_at >= $2::timestamptz and q.created_at < $3::timestamptz)::int as pending,
count(*) filter (where q.status = 'sent' and q.created_at >= $2::timestamptz and q.created_at < $3::timestamptz)::int as sent,
count(*) filter (where q.status in ('failed', 'discarded') and q.created_at >= $2::timestamptz and q.created_at < $3::timestamptz)::int as failed,
count(distinct q.record_id) filter (where q.created_at >= $2::timestamptz and q.created_at < $3::timestamptz)::int as "studentCount"
from public.crm_webhook_queue q
join public.tenant_student_followups sf on sf.tenant_id = q.tenant_id
and sf.metadata->'crmPush'->>'idempotencyKey' = q.idempotency_key
where q.tenant_id = $1
and q.source = 'tenant.student.crm_push'
and ${whereSql}
`,
params,
),
]);
const summaryRow = summary[0] || {};
const total = intValue(summaryRow.total, 0);
const done = intValue(summaryRow.done, 0);
const completedInRange = intValue(summaryRow.completedInRange, 0);
return {
item: {
range,
filters: {
assignedToUserId: assignedTo || null,
followupType: followupType || null,
classId: classId || null,
scoped: scopedIds !== null,
},
summary: {
total,
open: intValue(summaryRow.open, 0),
inProgress: intValue(summaryRow.inProgress, 0),
done,
cancelled: intValue(summaryRow.cancelled, 0),
highPriority: intValue(summaryRow.highPriority, 0),
crmPushTasks: intValue(summaryRow.crmPushTasks, 0),
openBacklog: intValue(summaryRow.openBacklog, 0),
overdueBacklog: intValue(summaryRow.overdueBacklog, 0),
completedInRange,
completionRate: ratio(done, total),
completedInRangeRate: ratio(completedInRange, total),
avgCompleteHours: Number(numberValue(summaryRow.avgCompleteHours, 0).toFixed(2)),
},
crmQueue: crmQueueSummary[0] || { total: 0, pending: 0, sent: 0, failed: 0, studentCount: 0 },
byStatus,
byType,
byPriority,
byAssignee: byAssignee.map(item => ({
...item,
avgCompleteHours: Number(numberValue(item.avgCompleteHours, 0).toFixed(2)),
completionRate: ratio(intValue(item.done, 0), intValue(item.total, 0)),
})),
byClass,
dailyTrend,
overdueItems: overdueItems.map(item => maskStudentFields(auth, {
...item,
overdueHours: Number(numberValue(item.overdueHours, 0).toFixed(2)),
})),
},
};
}
export async function upsertTenantStudentFollowupRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'students:followups:write');

View File

@@ -7,6 +7,7 @@ import {
tenantClassesRoute,
tenantClassMembersRoute,
tenantStudentFollowupsRoute,
tenantStudentFollowupReportRoute,
tenantStudentNotesRoute,
tenantStudentsRoute,
tenantTeachersRoute,
@@ -102,6 +103,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [
['GET', '/api/tenant-admin/students/notes', tenantStudentNotesRoute],
['PUT', '/api/tenant-admin/students/notes', upsertTenantStudentNoteRoute],
['GET', '/api/tenant-admin/students/followups', tenantStudentFollowupsRoute],
['GET', '/api/tenant-admin/students/followups/report', tenantStudentFollowupReportRoute],
['PUT', '/api/tenant-admin/students/followups', upsertTenantStudentFollowupRoute],
['GET', '/api/tenant-admin/teachers', tenantTeachersRoute],
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],

View File

@@ -6,6 +6,7 @@ import {
bulkUpsertTenantStudents,
loadTenantClasses,
loadTenantStudentFollowups,
loadTenantStudentFollowupReport,
loadTenantStudentNotes,
loadTenantStudents,
loadTenantTeachers,
@@ -22,6 +23,7 @@ import {
type TenantStudentItem,
type TenantStudentNoteItem,
type TenantStudentCrmPushInput,
type TenantStudentFollowupReport,
type TenantTeacherItem,
type TenantMemberItem,
} from '@/services/tenantAdmin';
@@ -122,6 +124,7 @@ export default function TenantStudentsPage() {
const [bulkResult, setBulkResult] = useState<BulkOperationResult | null>(null);
const [assignResult, setAssignResult] = useState<BulkOperationResult | null>(null);
const [crmPushResult, setCrmPushResult] = useState<BulkOperationResult | null>(null);
const [followupReport, setFollowupReport] = useState<TenantStudentFollowupReport['item'] | null>(null);
const [scoped, setScoped] = useState(false);
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -144,13 +147,15 @@ export default function TenantStudentsPage() {
limit: 80,
}),
loadTenantStudentFollowups({ status: 'open', limit: 30 }).catch(() => ({ items: [] })),
]).then(([classPayload, teacherPayload, memberPayload, studentPayload, followupPayload]) => {
loadTenantStudentFollowupReport({ timeRange: '30d', classId: nextClassId || undefined, limit: 8 }).catch(() => ({ item: null })),
]).then(([classPayload, teacherPayload, memberPayload, studentPayload, followupPayload, reportPayload]) => {
setClasses(classPayload.items || []);
setTeachers(teacherPayload.items || []);
setCrmAssignees((memberPayload.items || []).filter(item => crmAssignableRoles.includes(String(item.role || ''))));
setStudents(studentPayload.items || []);
setScoped(studentPayload.scoped === true);
setFollowups(followupPayload.items || []);
setFollowupReport(reportPayload.item || null);
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '学生数据加载失败'));
}
@@ -538,6 +543,38 @@ export default function TenantStudentsPage() {
{!students.length ? <View className='admin-empty'></View> : null}
</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'>{followupReport?.summary?.total || 0}</Text>
<Text className='admin-row-meta'> {followupReport?.summary?.done || 0} · CRM {followupReport?.summary?.crmPushTasks || 0}</Text>
</View>
<View className='admin-metric'>
<Text className='admin-metric-label'></Text>
<Text className='admin-metric-value'>{followupReport?.summary?.openBacklog || 0}</Text>
<Text className='admin-row-meta'> {followupReport?.summary?.overdueBacklog || 0} · {followupReport?.summary?.highPriority || 0}</Text>
</View>
<View className='admin-metric'>
<Text className='admin-metric-label'></Text>
<Text className='admin-metric-value'>{Math.round((followupReport?.summary?.completionRate || 0) * 100)}%</Text>
<Text className='admin-row-meta'> {followupReport?.summary?.avgCompleteHours || 0} </Text>
</View>
</View>
<View className='admin-list'>
{(followupReport?.byAssignee || []).slice(0, 4).map((item, index) => (
<View className='admin-row' key={`${item.assignedToUserId || 'none'}:${index}`}>
<Text className='admin-row-main'>{String(item.assignedToName || '未指派')} · {String(item.assignedToRole || '-')}</Text>
<Text className='admin-row-meta'> {String(item.total || 0)} · {String(item.open || 0)} · {String(item.done || 0)} · {String(item.overdue || 0)}</Text>
</View>
))}
</View>
{(followupReport?.overdueItems || []).slice(0, 3).map(item => (
<Text className='admin-row-meta break-line' key={item.id}> · {item.title || '跟进任务'} · {item.studentName || item.studentUserId || '-'} · {item.assignedToName || '未指派'}</Text>
))}
</View>
<View className='admin-section'>
<Text className='admin-section-title'>CRM </Text>
<View className='admin-form-grid'>

View File

@@ -138,6 +138,42 @@ export interface TenantStudentCrmPushInput {
idempotencyKey?: string;
}
export interface TenantStudentFollowupReport {
item?: {
range?: Record<string, unknown>;
filters?: Record<string, unknown>;
summary?: {
total?: number;
open?: number;
inProgress?: number;
done?: number;
cancelled?: number;
highPriority?: number;
crmPushTasks?: number;
openBacklog?: number;
overdueBacklog?: number;
completedInRange?: number;
completionRate?: number;
completedInRangeRate?: number;
avgCompleteHours?: number;
};
crmQueue?: {
total?: number;
pending?: number;
sent?: number;
failed?: number;
studentCount?: number;
};
byStatus?: Record<string, unknown>[];
byType?: Record<string, unknown>[];
byPriority?: Record<string, unknown>[];
byAssignee?: Record<string, unknown>[];
byClass?: Record<string, unknown>[];
dailyTrend?: Record<string, unknown>[];
overdueItems?: TenantStudentFollowupItem[];
};
}
export interface ImportJobItem {
id: string;
importType?: string;
@@ -1063,6 +1099,20 @@ export async function loadTenantStudentFollowups(query: {
});
}
export async function loadTenantStudentFollowupReport(query: {
timeRange?: '7d' | '30d' | '90d';
startDate?: string;
endDate?: string;
assignedToUserId?: string;
followupType?: string;
classId?: string;
limit?: number;
} = {}) {
return apiRequest<TenantStudentFollowupReport>('/api/tenant-admin/students/followups/report', {
query: { ...query, limit: query.limit || 10 },
});
}
export async function upsertTenantStudentFollowup(input: {
id?: string;
studentUserId: string;

View File

@@ -144,7 +144,7 @@
| 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches``activation-codes` |
| 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members``permissions``role-templates``audit-logs` |
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes``classes/members``students``teachers`,支持班级范围权限和审计 |
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups``students/crm-push`;支持逐行结果、限量、防跨租户和教师范围校验;批量 CRM 推送会为学生生成跟进任务并写入异步 CRM 队列,要求 `crm:write``students:read``students:followups:write`;学生导入/upsert/分班禁止 `avatarUrl/avatar_url/avatar/headimgurl/figureurl``primaryRole/primary_role`,避免绕过预设头像与租户角色体系 |
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups``students/followups/report``students/crm-push`;支持逐行结果、限量、防跨租户和教师范围校验;跟进报表支持 7/30/90 天或自定义日期范围、状态/类型/优先级/负责人/班级聚合、逾期待办、CRM 推送队列摘要和每日趋势;批量 CRM 推送会为学生生成跟进任务并写入异步 CRM 队列,要求 `crm:write``students:read``students:followups:write`;学生导入/upsert/分班禁止 `avatarUrl/avatar_url/avatar/headimgurl/figureurl``primaryRole/primary_role`,避免绕过预设头像与租户角色体系 |
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
| 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、平台员工列表、平台员工创建/编辑、平台员工禁用/恢复、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量平台 API 已拆分 `platform:staff:read/write/status``platform:tenant:read/write/status/billing_profile``platform:billing:read/write/payment/dunning/notification``platform:audit:read/export/alert/notification``platform:question_bank:read/grant/ops` 等权限点;审计导出、平台员工操作、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook发送成功会推进提醒状态失败会退避重试联系方式和请求 payload 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 |
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
@@ -163,7 +163,7 @@
| 销售统计/客户列表/团队 | 可联调 | `/api/referral/sales-*``team` |
| CRM 配置/队列 | 可联调 | `/api/crm/config``/api/crm/queue`CRM 配置支持 `none/direct/round_robin/referrer` 客资跟进分配策略、租户内候选成员校验、轮询游标、分配审计,客资首绑成功后会写入 `assignedToUserId` 并进入队列 payload |
| CRM webhook worker | 可联调 | `apps/worker` 已支持 generic webhook、钉钉、飞书、企微群机器人消息体/签名、`lead.created` 客资首绑事件和 `student.crm_push` 学生运营跟进事件、到期任务消费、失败退避重试、最终失败、discarded 和 `crm_webhook_log` |
| CRM 增强 | 待补齐 | 富卡片模板、失败告警、死信运营后台、跟进效果统计和更细销售转化看板 |
| CRM 增强 | 待补齐 | 富卡片模板、失败告警、死信运营后台和更细销售转化看板 |
| 分佣结算基础闭环 | 可联调 | `/api/commission/settings``member-rate``summary``orders``settlements``settlements/generate``settlements/status``settlements/export``settlements/proofs`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、CSV/JSON 导出、打款凭证登记/复核、销售/代理本人范围和租户隔离 |
| 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款 provider、发票管理、批量凭证上传、异常调整单和分佣看板 |

View File

@@ -22,7 +22,7 @@
- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者、日期和关键词查询平台审计;`GET /api/platform-admin/audit-logs/export` 支持平台管理员导出 CSV/JSON返回 base64 内容、sha256、行数和筛选条件并对 `details` 中的 token/secret/password/key 等敏感字段递归脱敏,同时写入 `platform.audit.exported` 审计;`GET /api/platform-admin/audit-alert-rules``GET /api/platform-admin/audit-alerts``POST /api/platform-admin/audit-alerts/status` 支持平台内部审计告警规则查询、开放告警查询、确认/解决/忽略API 返回告警 details 时递归脱敏敏感字段;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`GET/PUT /api/platform-admin/audit-notification-channels``GET /api/platform-admin/audit-notification-events` 已支持平台审计告警外部通知渠道配置和发送事件查询,`apps/worker --job platform-audit-notifications` 可按渠道把开放告警推送到 generic/钉钉/飞书/企微 webhook签名密钥进入 `app_private.platform_secrets`API 只回显 `secretRef` 和 webhook host/path`GET /api/platform-admin/invoices/subscription-candidates``POST /api/platform-admin/invoices/from-subscriptions-batch` 支持订阅账单候选预览、dry-run、批量生成、重复开票跳过和平台审计`apps/worker --job platform-billing` 可自动为即将到期且未开票订阅生成服务费账单;`POST /api/platform-admin/invoices/process-overdue``GET /api/platform-admin/invoices/reminders``apps/worker --job platform-dunning` 可处理已逾期未结清服务费账单,写入内部催缴台账和审计;`GET/PUT /api/platform-admin/dunning-notification-channels``GET /api/platform-admin/dunning-notification-events``apps/worker --job platform-dunning-notifications` 已支持平台催缴外部通知渠道配置、发送事件查询、重试和幂等发送。创建租户、状态变更、账务资料维护、订阅批量开票、自动计费、逾期催缴、手工收款确认、审计导出、审计告警状态更新、审计告警通知渠道变更和催缴通知渠道变更会写入审计日志API/worker 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝、敏感字段脱敏和审计记录存在。
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
- 班级与学生范围权限已落库:`tenant_classes``tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
- 学生运营管理已落库:`tenant_student_notes``tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复学生批量 CRM 推送已接入权限校验、范围校验和集成测试。
- 学生运营管理已落库:`tenant_student_notes``tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复学生批量 CRM 推送和跟进效果统计已接入权限校验、范围校验和集成测试。`GET /api/tenant-admin/students/followups/report` 支持 7/30/90 天或自定义日期范围、负责人/类型/班级筛选、状态/类型/优先级/负责人/班级聚合、每日趋势、逾期待办和 CRM 队列摘要,并会沿用教师班级范围与字段脱敏规则。
- 旧题库常用运营功能已补齐一批:`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 快照保护由后端强制执行。
@@ -219,6 +219,8 @@ GET /api/tenant-admin/students/notes
PUT /api/tenant-admin/students/notes
GET /api/tenant-admin/students/followups
PUT /api/tenant-admin/students/followups
GET /api/tenant-admin/students/followups/report
POST /api/tenant-admin/students/crm-push
GET /api/tenant-admin/teachers
GET /api/tenant-admin/overview
PUT /api/tenant-admin/branding

View File

@@ -106,4 +106,4 @@ pending -> discarded
- 钉钉/飞书/企微富卡片模板。
- 失败告警和死信运营后台。
- 跟进效果统计和销售转化看板。
- 更细销售转化看板。

View File

@@ -30,7 +30,7 @@
## 当前可进入的前端工作
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表、积分任务/兑换、积分风控只读摘要和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注跟进任务、跟进看板和 CRM 批量推送第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表、积分任务/兑换、积分风控只读摘要和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权、平台员工;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,平台员工页已接员工列表、搜索、创建/编辑、权限点勾选、禁用和恢复,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV并可查看开放审计告警、确认或解决告警也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成和催缴记录查看。
- 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。
- 可以按新后端主模型接入内容导航:
@@ -48,6 +48,7 @@
- 租户后台可以接入角色模板和成员 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/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` 做用户通知查看。通知只做展示、跳转和已读/归档状态,业务权限和权益仍以后端源接口为准。
- 学生头像是产品级约束:学生端只展示男女预设头像,前端不要做头像上传、裁剪、对象存储签名或微信/QQ 头像同步;排行榜也是可选活动能力,默认不请求、不展示,日常学习激励以后台配置勋章自动发放为主。
## 不能误认为已商用完成的部分
@@ -94,13 +95,13 @@
| --- | --- | --- |
| 工作台 | `apps/taro/src/pages/tenant-admin/workbench/index.tsx` | `tenant-admin/overview``tenant-admin/dashboard``tenant-admin/permissions` |
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` |
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes``tenant-admin/teachers``tenant-admin/students``students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups``students/crm-push` |
| 学生运营 | `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``students/followups/report``students/crm-push` |
| 题库内容 | `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/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` |
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务、完成跟进和 CRM 入队推送;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置、领取/兑换记录查看、积分风控只读摘要和用户通知查看第一版;财务运营页支持退款状态流、官方账单任务、对账异常、差错工单和调整凭证,且只通过后端命令层写审计与状态;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、主题素材库、真实打款 provider、发票、真实生产账单抽样验收和小程序端兼容。
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务、完成跟进、跟进看板和 CRM 入队推送;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置、领取/兑换记录查看、积分风控只读摘要和用户通知查看第一版;财务运营页支持退款状态流、官方账单任务、对账异常、差错工单和调整凭证,且只通过后端命令层写审计与状态;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、主题素材库、真实打款 provider、发票、真实生产账单抽样验收和小程序端兼容。
## 已落地的 Taro 平台后台页面

View File

@@ -42,7 +42,7 @@
| 旧功能/组件 | 新后端状态 | 待补齐 |
| --- | --- | --- |
| 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务和批量 CRM 推送已实现;补绑、学习督导自动化和跟进效果统计待补 |
| 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务、跟进效果统计和批量 CRM 推送已实现;后续补学习督导自动化、更细导入模板体验和更细数据范围 UI |
| 销售/代理管理 | 部分覆盖 | referral/team/stats 已有;分佣设置、订单/激活码归因、结算单生成、审核、打款状态、CSV/JSON 导出、打款凭证登记/复核和本人/全局权限范围已覆盖;还缺真实打款 provider、发票、批量凭证上传和分佣看板 |
| 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 |
| 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 |
@@ -66,7 +66,7 @@
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
| 用户站内通知 | 部分覆盖 | 反馈处理、反馈奖励、勋章发放和积分兑换会写入 `user_notifications`,学生可查/标记状态租户后台可按权限查看租户内通知Taro 学生个人中心已接消息筛选/已读/归档第一版,租户营销中心已接用户通知查看第一版 | 独立消息中心增强、外部微信订阅消息/短信和批量统计待补 |
| CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、学生批量 CRM 推送、钉钉/飞书/企微 worker、签名和重试已有富卡片模板、失败告警、死信运营台和跟进效果统计待补 |
| CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、学生批量 CRM 推送、跟进效果统计、钉钉/飞书/企微 worker、签名和重试已有富卡片模板、失败告警、死信运营台和更细销售转化看板待补 |
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 |
## 平台 SaaS 后台功能
@@ -102,7 +102,7 @@
4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。
5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
7. CRM/销售结算CRM worker、跟进分配策略、分佣规则、结算单、审核、打款状态、导出和凭证复核基础闭环已完成仍缺富卡片模板、失败告警、死信运营台、真实打款 provider、发票和销售结算看板。
7. CRM/销售结算CRM worker、跟进分配策略、学生批量 CRM 推送、跟进效果统计、分佣规则、结算单、审核、打款状态、导出和凭证复核基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、真实打款 provider、发票和销售结算看板。
8. AI 择校推荐增强:后端 `local_rules` 地基、SVIP 门禁、报告列表/详情和 Taro 基础页已完成;仍缺真实 AI provider、prompt 编排、PDF 报告和后台运营配置。
9. 题目反馈增强:站内通知后端第一版已完成;仍缺前端消息中心、外部订阅消息/短信提醒、问题聚合统计和内容修复闭环。
10. 积分活动增强:积分兑换、活动任务、优惠券兑换履约、后台配置和积分风控只读报表第一阶段已完成;仍缺活动效果看板和更细系统任务触发。

View File

@@ -20,7 +20,7 @@
- 内容导入:题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、校验、导入、幂等、审计。
- 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务、批量 CRM 推送已完成接口和集成测试;学生写入口已拒绝头像 URL、第三方头像字段和 `primaryRole`学生头像统一走男女预设Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注、跟进任务和 CRM 推送第一版;后续补自动学习督导、跟进效果统计和更细导入模板体验。
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务、跟进效果统计、批量 CRM 推送已完成接口和集成测试;学生写入口已拒绝头像 URL、第三方头像字段和 `primaryRole`学生头像统一走男女预设Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注、跟进任务、跟进看板和 CRM 推送第一版;后续补自动学习督导和更细导入模板体验。
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、反馈运营聚合报表、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。学生激励以后台配置勋章自动发放为主,不默认启用排行榜竞争。后续补更细活动效果看板和更细前端运营配置体验。
- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单兑换完成或待履约会写入用户站内通知Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台、反馈运营摘要和积分风控只读摘要第一版。后续补更细活动效果看板。
@@ -159,9 +159,9 @@
- 继续补真实打款 provider、发票管理、批量凭证上传、销售团队分佣看板和异常调整单。
12. 学生运营管理
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注、跟进任务和批量 CRM 推送;学生写入口拒绝头像 URL、第三方头像字段和 `primaryRole`,避免绕过预设头像与租户角色体系。
- 已完成 Taro 学生运营页第一版:学生创建/更新、状态禁用/恢复、批量导入、批量分班、备注、跟进任务、完成跟进和 CRM 入队推送。
- 继续补学习督导自动化、跟进效果统计和更细导入模板体验。
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注、跟进任务、跟进效果统计和批量 CRM 推送;学生写入口拒绝头像 URL、第三方头像字段和 `primaryRole`,避免绕过预设头像与租户角色体系。
- 已完成 Taro 学生运营页第一版:学生创建/更新、状态禁用/恢复、批量导入、批量分班、备注、跟进任务、完成跟进、跟进看板和 CRM 入队推送。
- 继续补学习督导自动化和更细导入模板体验。
13. AI 择校推荐
- 已完成 `ai_recommendation_reports` 报告台账、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON 输出、报告列表/详情接口和 Taro 学生端基础页。
@@ -185,7 +185,7 @@
3. CRM worker
- 已完成 `apps/worker` CRM 队列消费、generic webhook、钉钉、飞书、企业微信机器人 adapter、签名、失败重试和日志。
- 已完成 CRM 配置中的跟进分配策略:`none/direct/round_robin/referrer`,后端校验候选人必须是当前租户内 active 的销售/代理/运营/教师/管理员,首绑客资后写入 `assignedToUserId` 并把 assignee 放进 CRM 队列 payload。
- 继续补富卡片模板、失败告警、死信运营后台和批量 CRM 推送
- 继续补富卡片模板、失败告警、死信运营后台和更细销售转化看板
4. 运维
- 后台操作审计报表。
@@ -241,7 +241,7 @@
- 会员权益、订单、激活码兑换
- 错题本、收藏夹、学习统计、勋章、站内通知,排行榜仅在租户开启后展示
- 男女预设头像契约已接,页面 UI 待接入;不做用户头像上传,也不要把微信/QQ 返回头像写入学生资料
- Taro 已接学习报告、14 天趋势、题型表现最近练习和 7 日答题榜当前排名第一版
- Taro 已接学习报告、14 天趋势、题型表现最近练习第一版;排行榜仅在租户显式开启 `enableLeaderboard` 并完成压测后进入独立排行榜页或活动页,个人中心默认不请求
- 会员收银台、优惠券领取、下单、支付参数、订单详情、状态轮询和售后入口已接第一版
### 前端接入原则

View File

@@ -275,6 +275,7 @@ tenant:<tenantId>:theme
| 租户学生 | `GET/PUT /api/tenant-admin/students``POST /api/tenant-admin/students/bulk-upsert``POST /api/tenant-admin/students/status` |
| 学生备注 | `GET/PUT /api/tenant-admin/students/notes` |
| 学生跟进任务 | `GET/PUT /api/tenant-admin/students/followups` |
| 学生跟进看板 | `GET /api/tenant-admin/students/followups/report?timeRange=30d&classId=...` |
| 租户教师 | `GET /api/tenant-admin/teachers` |
| 租户考试日期 | `GET/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` |
@@ -1478,6 +1479,7 @@ content_entries
- 学生手机号、订单金额、客资归属等敏感字段按 `fieldPermissions` 控制显示;字段被后端返回为 `null` 时前端展示脱敏占位,不要从其它接口补取。
- 学生批量导入和批量分班接口会返回 `total/successCount/errorCount/results`,前端必须展示逐行错误,不要在浏览器端静默丢弃失败行。学生导入模板不要提供头像 URL 或 `primaryRole` 字段;后端会拒绝这类字段,学生头像统一由个人中心 `avatarPreset` 控制。
- 学生批量 CRM 推送统一调用 `POST /api/tenant-admin/students/crm-push`,请求包含 `studentUserIds/title/message/followupType/priority/assignedToUserId/classId/dueAt/idempotencyKey`。前端不要直接写 `crm_webhook_queue`,也不要持有 webhook 密钥;后端会校验 `crm:write`、学生范围、班级范围、指派人租户成员和幂等键,并返回逐行 `queueId/followupId/errors`
- 学生跟进看板统一调用 `GET /api/tenant-admin/students/followups/report`,不要由前端直接读取 `tenant_student_followups``crm_webhook_queue` 后自行聚合。该接口支持 `timeRange=7d|30d|90d``startDate/endDate``assignedToUserId``followupType``classId``limit`,返回 `summary/crmQueue/byStatus/byType/byPriority/byAssignee/byClass/dailyTrend/overdueItems`;后端会沿用当前账号的租户权限、教师班级范围和手机号脱敏规则。
- 教师可以为范围内学生创建备注和跟进任务,但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定;前端只按返回值渲染。
- H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。
@@ -3049,7 +3051,7 @@ GET /api/platform-admin/dunning-notification-events?invoiceId=<invoiceId>&limit=
下一批前端开发重点:
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息中心、积分任务、积分兑换和积分明细第一版已接;下一批继续补独立消息中心增强、真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务完成跟进、跟进看板和 CRM 批量推送第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。
- 平台后台:租户创建、租户详情、状态变更、账务资料维护、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件状态摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑已接第一版后端会跳过已开票订阅并记录 `platform.invoice.subscription_batch_created` 审计,`platform-billing` worker 会自动生成即将到期订阅账单并记录 `platform.invoice.subscription_auto_created` 审计,`platform-dunning` worker 会标记已过期未结清服务费账单、生成 `tenant_invoice_reminders` 并记录 `platform.invoice.overdue_processed` 审计,`platform-dunning-notifications` worker 会把内部催缴记录按渠道发送外部通知,`platform-audit-alerts` worker 会把高风险平台审计动作转换为内部告警,`platform-audit-notifications` worker 会把开放告警按渠道发送外部通知;前端只展示候选、预览结果、跳过结果、逾期处理结果、开放告警、通知事件和生成后的账单/审计,不要直接更新账单状态、租户 `billing_status`、告警表或通知事件表;继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
- 小程序:验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性;如不稳定,保留 `apps/api/auth/*` 作为小程序登录适配层。
@@ -3143,7 +3145,7 @@ GET /api/ai/school-recommendations/detail?reportId=<reportId>
- 品牌/主题/域名/公开设置
- 支付账户/登录 provider/密钥引用
- 用户与成员权限
- 班级/教师/学生:`/api/tenant-admin/classes``classes/members``students``teachers``students/notes``students/followups`
- 班级/教师/学生:`/api/tenant-admin/classes``classes/members``students``teachers``students/notes``students/followups``students/followups/report``students/crm-push`
- 角色模板与成员:`GET/PUT /api/tenant-admin/role-templates``POST /api/tenant-admin/role-templates/disable``GET/PUT /api/tenant-admin/members``POST /api/tenant-admin/members/disable`;当前 Taro 租户设置页已提供第一版创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员绑定模板和成员状态配置。
- 主题模板:`GET /api/tenant-admin/theme-templates``GET /api/tenant-admin/theme``POST /api/tenant-admin/theme/preview``POST /api/tenant-admin/theme/publish`;当前 Taro 租户设置页已提供第一版模板选择、主色/强调色、Logo/分享图、安全草稿预览和发布。
- 内容入口/分类树/题目集合/练习蓝图

View File

@@ -9341,6 +9341,21 @@ async function testTenantStudentOperations() {
assert.equal(completedFollowup.item?.status, 'done', 'teacher should complete scoped follow-up');
assert.equal(completedFollowup.item?.completedBy, TENANT_TEACHER_USER_ID, 'completed follow-up should record completer');
const teacherFollowupReport = await request('/api/tenant-admin/students/followups/report', {
userId: TENANT_TEACHER_USER_ID,
query: { timeRange: '30d', limit: 10 },
});
assert.equal(teacherFollowupReport.item?.filters?.scoped, true, 'teacher follow-up report should be class scoped');
assert.equal(teacherFollowupReport.item?.summary?.total >= 1, true, 'teacher follow-up report should include scoped follow-up');
assert.ok(
teacherFollowupReport.item?.byAssignee?.some(item => item.assignedToUserId === TENANT_TEACHER_USER_ID),
'teacher follow-up report should include scoped assignee summary',
);
assert.ok(
!teacherFollowupReport.item?.overdueItems?.some(item => item.studentUserId === SECOND_STUDENT_USER_ID),
'teacher follow-up report must not expose unscoped students',
);
const teacherOtherFollowupDenied = await request('/api/tenant-admin/students/followups', {
userId: TENANT_TEACHER_USER_ID,
method: 'PUT',
@@ -9354,6 +9369,20 @@ async function testTenantStudentOperations() {
});
assert.equal(teacherOtherFollowupDenied.code, 'STUDENT_SCOPE_REQUIRED', 'teacher should not create follow-up for unscoped student');
const invalidFollowupReportRange = await request('/api/tenant-admin/students/followups/report', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '365d' },
expectStatus: 400,
});
assert.equal(invalidFollowupReportRange.code, 'INVALID_FOLLOWUP_REPORT_RANGE', 'follow-up report should validate time range');
const invalidFollowupReportDate = await request('/api/tenant-admin/students/followups/report', {
userId: TENANT_ADMIN_USER_ID,
query: { startDate: '2026-02-31', endDate: '2026-03-01' },
expectStatus: 400,
});
assert.equal(invalidFollowupReportDate.code, 'INVALID_DATE_RANGE', 'follow-up report should reject impossible calendar dates');
const studentStatusDenied = await request('/api/tenant-admin/students/status', {
userId: TENANT_TEACHER_USER_ID,
method: 'POST',
@@ -9525,6 +9554,35 @@ async function testReferralAndCrmGrowth() {
'student CRM class scoped push should reject students outside the selected class',
);
const followupReport = await request('/api/tenant-admin/students/followups/report', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '30d', limit: 10 },
});
assert.equal(followupReport.item?.summary?.crmPushTasks >= 2, true, 'follow-up report should count CRM push tasks');
assert.equal(followupReport.item?.crmQueue?.total >= 2, true, 'follow-up report should include student CRM queue totals');
assert.ok(
followupReport.item?.byAssignee?.some(item => item.assignedToUserId === TENANT_SALES_USER_ID && Number(item.crmPushTasks || 0) >= 2),
'follow-up report should group CRM push tasks by assignee',
);
assert.ok(
followupReport.item?.dailyTrend?.some(item => Number(item.crmPushCreated || 0) >= 1),
'follow-up report should include CRM push daily trend',
);
const classFollowupReport = await request('/api/tenant-admin/students/followups/report', {
userId: TENANT_ADMIN_USER_ID,
query: { timeRange: '30d', classId: ids.tenantClass, limit: 10 },
});
assert.equal(
classFollowupReport.item?.summary?.crmPushTasks >= 1,
true,
'class-scoped follow-up report should include in-class CRM push tasks',
);
assert.ok(
!classFollowupReport.item?.overdueItems?.some(item => item.studentUserId === SECOND_STUDENT_USER_ID),
'class-scoped follow-up report should not expose another class student overdue items',
);
const operatorCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
userId: TENANT_OPERATOR_USER_ID,
method: 'POST',