diff --git a/README.md b/README.md index cd4c49b8..8cadc2ed 100644 --- a/README.md +++ b/README.md @@ -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:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 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:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、积分任务/兑换/积分明细和消息中心第一版;学生端不默认请求排行榜,仅在租户显式开启 `enableLeaderboard` 并完成压测后进入独立排行榜页或活动页;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,学生运营页已接跟进看板和批量 CRM 推送第一版,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权、平台员工管理,以及创建租户、租户详情、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、权限点勾选、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 更完整的进度看这些文档: diff --git a/apps/api/src/features/tenant-admin/classes.ts b/apps/api/src/features/tenant-admin/classes.ts index 10408f94..95774cf3 100644 --- a/apps/api/src/features/tenant-admin/classes.ts +++ b/apps/api/src/features/tenant-admin/classes.ts @@ -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 = { '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>( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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'); diff --git a/apps/api/src/features/tenant-admin/index.ts b/apps/api/src/features/tenant-admin/index.ts index 95757056..bc7f8b1d 100644 --- a/apps/api/src/features/tenant-admin/index.ts +++ b/apps/api/src/features/tenant-admin/index.ts @@ -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], diff --git a/apps/taro/src/pages/tenant-admin/students/index.tsx b/apps/taro/src/pages/tenant-admin/students/index.tsx index 76b8e7d6..639f6376 100644 --- a/apps/taro/src/pages/tenant-admin/students/index.tsx +++ b/apps/taro/src/pages/tenant-admin/students/index.tsx @@ -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(null); const [assignResult, setAssignResult] = useState(null); const [crmPushResult, setCrmPushResult] = useState(null); + const [followupReport, setFollowupReport] = useState(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 ? 暂无学生,或当前角色没有可见学生范围。 : null} + + 跟进看板 + + + 30 天跟进 + {followupReport?.summary?.total || 0} + 完成 {followupReport?.summary?.done || 0} · CRM {followupReport?.summary?.crmPushTasks || 0} + + + 待处理 + {followupReport?.summary?.openBacklog || 0} + 逾期 {followupReport?.summary?.overdueBacklog || 0} · 高优先级 {followupReport?.summary?.highPriority || 0} + + + 完成率 + {Math.round((followupReport?.summary?.completionRate || 0) * 100)}% + 平均 {followupReport?.summary?.avgCompleteHours || 0} 小时 + + + + {(followupReport?.byAssignee || []).slice(0, 4).map((item, index) => ( + + {String(item.assignedToName || '未指派')} · {String(item.assignedToRole || '-')} + 总数 {String(item.total || 0)} · 待处理 {String(item.open || 0)} · 完成 {String(item.done || 0)} · 逾期 {String(item.overdue || 0)} + + ))} + + {(followupReport?.overdueItems || []).slice(0, 3).map(item => ( + 逾期 · {item.title || '跟进任务'} · {item.studentName || item.studentUserId || '-'} · {item.assignedToName || '未指派'} + ))} + + CRM 批量推送 diff --git a/apps/taro/src/services/tenantAdmin.ts b/apps/taro/src/services/tenantAdmin.ts index 0f22e6f8..569db1e6 100644 --- a/apps/taro/src/services/tenantAdmin.ts +++ b/apps/taro/src/services/tenantAdmin.ts @@ -138,6 +138,42 @@ export interface TenantStudentCrmPushInput { idempotencyKey?: string; } +export interface TenantStudentFollowupReport { + item?: { + range?: Record; + filters?: Record; + 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[]; + byType?: Record[]; + byPriority?: Record[]; + byAssignee?: Record[]; + byClass?: Record[]; + dailyTrend?: Record[]; + 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('/api/tenant-admin/students/followups/report', { + query: { ...query, limit: query.limit || 10 }, + }); +} + export async function upsertTenantStudentFollowup(input: { id?: string; studentUserId: string; diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index d87d7c17..2300ed2f 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -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、发票管理、批量凭证上传、异常调整单和分佣看板 | diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 62d59419..f2120606 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -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 diff --git a/docs/refactor/crm-worker.md b/docs/refactor/crm-worker.md index 4b7c4839..bbb31897 100644 --- a/docs/refactor/crm-worker.md +++ b/docs/refactor/crm-worker.md @@ -106,4 +106,4 @@ pending -> discarded - 钉钉/飞书/企微富卡片模板。 - 失败告警和死信运营后台。 -- 跟进效果统计和销售转化看板。 +- 更细销售转化看板。 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index a5f250ea..634e045d 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -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 平台后台页面 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index f06204aa..89811495 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -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. 积分活动增强:积分兑换、活动任务、优惠券兑换履约、后台配置和积分风控只读报表第一阶段已完成;仍缺活动效果看板和更细系统任务触发。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index fcff388a..4555ef27 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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` 并完成压测后进入独立排行榜页或活动页,个人中心默认不请求 - 会员收银台、优惠券领取、下单、支付参数、订单详情、状态轮询和售后入口已接第一版 ### 前端接入原则 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 2f614a87..2f77d6ed 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -275,6 +275,7 @@ tenant::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=&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= - 品牌/主题/域名/公开设置 - 支付账户/登录 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/分享图、安全草稿预览和发布。 - 内容入口/分类树/题目集合/练习蓝图 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index e4313c6a..1d60e7b8 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -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',