feat: add student crm push

This commit is contained in:
Codex
2026-06-30 16:21:17 +08:00
parent d94b20b8ed
commit 7f97b52165
15 changed files with 774 additions and 27 deletions

View File

@@ -12,14 +12,14 @@
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
- `apps/api` 独立业务 API后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 - `apps/api` 独立业务 API后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、学生批量 CRM 推送、审计日志。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口租户默认关闭、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口租户默认关闭、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。
- 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。 - 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目同步会保护租户自改题目返回冲突而不覆盖后台可查询冲突明细worker 失败会生成租户 `public_question_bank_sync_failed` 通知,恢复成功自动关闭失败通知,平台可用 `/api/platform-admin/question-bank-sync-status``platform:question_bank:ops` 查看跨租户同步运营摘要。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目同步会保护租户自改题目返回冲突而不覆盖后台可查询冲突明细worker 失败会生成租户 `public_question_bank_sync_failed` 通知,恢复成功自动关闭失败通知,平台可用 `/api/platform-admin/question-bank-sync-status``platform:question_bank:ops` 查看跨租户同步运营摘要。
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets``daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets``daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略、客资队列和学生批量 CRM 跟进推送
- `apps/worker` 后台任务进程CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志commerce worker 可补偿查询微信/支付宝支付和退款状态provider-bills worker 可下载微信/支付宝官方账单并导入资金对账platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计platform-dunning-notifications worker 可把内部催缴记录按平台渠道推送到 generic/钉钉/飞书/企微 webhook并记录幂等发送事件platform-audit-alerts worker 可把高风险平台审计动作转换为内部告警并递归脱敏告警 detailsplatform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook并记录幂等发送事件assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源imports worker 可执行大批量导入public-banks worker 可自动同步公共题库采纳副本exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。 - `apps/worker` 后台任务进程CRM webhook 队列消费、`lead.created` 客资事件、`student.crm_push` 学生跟进事件、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志commerce worker 可补偿查询微信/支付宝支付和退款状态provider-bills worker 可下载微信/支付宝官方账单并导入资金对账platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计platform-dunning-notifications worker 可把内部催缴记录按平台渠道推送到 generic/钉钉/飞书/企微 webhook并记录幂等发送事件platform-audit-alerts worker 可把高风险平台审计动作转换为内部告警并递归脱敏告警 detailsplatform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook并记录幂等发送事件assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源imports worker 可执行大批量导入public-banks worker 可自动同步公共题库采纳副本exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 - 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
- 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。 - 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。

View File

@@ -1,3 +1,4 @@
import { createHash, randomUUID } from 'node:crypto';
import type pg from 'pg'; import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js'; import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js'; import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
@@ -22,13 +23,33 @@ const STUDENT_FOLLOWUP_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
const STUDENT_FOLLOWUP_STATUSES = ['open', 'in_progress', 'done', 'cancelled']; const STUDENT_FOLLOWUP_STATUSES = ['open', 'in_progress', 'done', 'cancelled'];
const MAX_BULK_STUDENTS = 200; const MAX_BULK_STUDENTS = 200;
const MAX_BULK_CLASS_ASSIGNMENTS = 500; const MAX_BULK_CLASS_ASSIGNMENTS = 500;
const MAX_BULK_CRM_PUSH = 100;
const CRM_ASSIGNABLE_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'];
const STUDENT_AVATAR_FIELD_KEYS = ['avatarUrl', 'avatar_url', 'avatar', 'headimgurl', 'headImgUrl', 'figureurl', 'figureurl_qq_1', 'figureurl_qq_2']; 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 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;
function nullableString(value: unknown) { function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
} }
function limitedNullableString(value: unknown, maxLength: number, fieldName: string) {
const candidate = nullableString(value);
if (!candidate) return null;
if (candidate.length > maxLength) {
throw new HttpError(400, `${fieldName} is too long`, 'FIELD_TOO_LONG');
}
return candidate;
}
function requiredLimitedString(body: JsonBody, key: string, maxLength: number) {
const value = requiredString(body, key);
if (value.length > maxLength) {
throw new HttpError(400, `${key} is too long`, 'FIELD_TOO_LONG');
}
return value;
}
function objectValue(value: unknown): Record<string, unknown> { function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
} }
@@ -80,7 +101,42 @@ function uuidArrayValue(value: unknown) {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return value return value
.map(item => (typeof item === 'string' ? item.trim() : '')) .map(item => (typeof item === 'string' ? item.trim() : ''))
.filter(item => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(item)); .filter(item => UUID_PATTERN.test(item));
}
function strictUuidArrayValue(value: unknown, fieldName: string, maxLength: number) {
if (!Array.isArray(value)) {
throw new HttpError(400, `${fieldName} must be an array`, 'INVALID_ARRAY_FIELD');
}
if (value.length > maxLength) {
throw new HttpError(413, `${fieldName} supports at most ${maxLength} items`, 'BULK_LIMIT_EXCEEDED');
}
const items: string[] = [];
const seen = new Set<string>();
for (let index = 0; index < value.length; index += 1) {
const item = typeof value[index] === 'string' ? value[index].trim() : '';
if (!UUID_PATTERN.test(item)) {
throw new HttpError(400, `${fieldName}[${index}] must be a uuid`, 'INVALID_UUID_FIELD');
}
const normalized = item.toLowerCase();
if (!seen.has(normalized)) {
seen.add(normalized);
items.push(normalized);
}
}
if (!items.length) throw new HttpError(400, `${fieldName} is required`, 'REQUIRED_FIELD');
return items;
}
function optionalTimestampString(value: unknown, fieldName: string) {
const candidate = nullableString(value);
if (!candidate) return null;
const time = Date.parse(candidate);
if (!Number.isFinite(time)) {
throw new HttpError(400, `${fieldName} must be a valid datetime`, 'INVALID_DATETIME');
}
return new Date(time).toISOString();
} }
function classCodeValue(value: unknown) { function classCodeValue(value: unknown) {
@@ -353,6 +409,150 @@ async function ensureTenantMembership(
); );
} }
interface StudentCrmPushConfig {
enabled: boolean;
url: string | null;
formName: string | null;
examType: string | null;
delaySec: number | null;
}
async function loadCrmPushConfig(client: pg.PoolClient, tenantId: string) {
const result = await client.query<StudentCrmPushConfig>(
`
select enabled, url, form_name as "formName", exam_type as "examType", delay_sec as "delaySec"
from public.crm_config
where tenant_id = $1
limit 1
`,
[tenantId],
);
const config = result.rows[0] || null;
if (!config?.enabled || !config.url) {
throw new HttpError(409, 'CRM config is disabled or missing webhook URL', 'CRM_CONFIG_REQUIRED');
}
return config;
}
function crmPushIdempotencyKey(inputKey: string | null, requestId: string, studentUserId: string) {
const rawKey = inputKey || requestId;
const normalized = rawKey.length <= 80 ? rawKey : createHash('sha256').update(rawKey).digest('hex');
return `student_crm_push:${normalized}:${studentUserId}`;
}
async function loadStudentCrmPayload(
client: pg.PoolClient,
auth: TenantAdminAuth,
studentUserId: string,
includePhone: boolean,
) {
const result = await client.query<Record<string, unknown>>(
`
with class_agg as (
select tcm.tenant_id, tcm.user_id,
jsonb_agg(
jsonb_build_object(
'classId', tc.id,
'className', tc.name,
'classCode', tc.code
)
order by tc.sort_order asc, tc.created_at desc
) filter (where tcm.status = 'active') as classes
from public.tenant_class_members tcm
join public.tenant_classes tc on tc.tenant_id = tcm.tenant_id and tc.id = tcm.class_id
where tcm.tenant_id = $1
and tcm.user_id = $2::uuid
and tcm.member_type = 'student'
group by tcm.tenant_id, tcm.user_id
)
select u.id as "userId", u.username, u.name,
case when $3::boolean then u.phone else null end as phone,
u.email::text as email,
coalesce(sp.avatar_preset, 'male') as "avatarPreset",
sp.region_id as "regionId", r.name as "regionName",
sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName",
sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName",
coalesce(ca.classes, '[]'::jsonb) as classes
from public.tenant_memberships tm
join public.platform_users u on u.id = tm.user_id
left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id
left join public.regions r on r.tenant_id = tm.tenant_id and r.id = sp.region_id
left join public.schools s on s.tenant_id = tm.tenant_id and s.id = sp.selected_school_id
left join public.majors m on m.tenant_id = tm.tenant_id and m.id = sp.selected_major_id
left join class_agg ca on ca.tenant_id = tm.tenant_id and ca.user_id = tm.user_id
where tm.tenant_id = $1
and tm.user_id = $2::uuid
and tm.role = 'student'
and tm.status = 'active'
limit 1
`,
[auth.tenantId, studentUserId, includePhone],
);
if (!result.rows[0]) throw new HttpError(404, 'Student not found', 'STUDENT_NOT_FOUND');
return result.rows[0];
}
async function loadCrmAssigneePayload(client: pg.PoolClient, tenantId: string, userId: string | null) {
if (!userId) return null;
const result = await client.query<Record<string, unknown>>(
`
select u.id as "userId", u.username, u.name, tm.role
from public.tenant_memberships tm
join public.platform_users u on u.id = tm.user_id
where tm.tenant_id = $1
and tm.user_id = $2::uuid
and tm.role = any($3::text[])
and tm.status = 'active'
order by case tm.role
when 'tenant_owner' then 1
when 'tenant_admin' then 2
when 'tenant_operator' then 3
when 'teacher' then 4
when 'sales' then 5
when 'agent' then 6
else 9
end
limit 1
`,
[tenantId, userId, CRM_ASSIGNABLE_ROLES],
);
return result.rows[0] || null;
}
async function loadCrmClassPayload(client: pg.PoolClient, tenantId: string, classId: string | null) {
if (!classId) return null;
const result = await client.query<Record<string, unknown>>(
`
select id as "classId", code as "classCode", name as "className"
from public.tenant_classes
where tenant_id = $1 and id = $2::uuid
limit 1
`,
[tenantId, classId],
);
return result.rows[0] || null;
}
async function ensureStudentInClass(client: pg.PoolClient, tenantId: string, studentUserId: string, classId: string | null) {
if (!classId) return;
const result = await client.query<{ id: string }>(
`
select id
from public.tenant_class_members
where tenant_id = $1
and class_id = $2::uuid
and user_id = $3::uuid
and member_type = 'student'
and status = 'active'
limit 1
`,
[tenantId, classId, studentUserId],
);
if (!result.rows[0]) {
throw new HttpError(400, 'Student is not an active member of the selected class', 'STUDENT_CLASS_REQUIRED');
}
}
function membershipRoleForClassMember(memberType: string) { function membershipRoleForClassMember(memberType: string) {
return memberType === 'student' ? 'student' : 'teacher'; return memberType === 'student' ? 'student' : 'teacher';
} }
@@ -1267,3 +1467,209 @@ export async function upsertTenantStudentFollowupRoute(ctx: RequestContext) {
return { item }; return { item };
} }
export async function pushTenantStudentsToCrmRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'crm:write');
requireTenantPermission(auth, 'students:read');
requireTenantPermission(auth, 'students:followups:write');
const body = await readJsonBody(ctx);
const studentUserIds = strictUuidArrayValue(body.studentUserIds || body.userIds, 'studentUserIds', MAX_BULK_CRM_PUSH);
const classId = nullableString(body.classId);
if (classId) await ensureReadableClass(auth, classId);
const source = limitedNullableString(body.source, 80, 'source') || 'manual_batch';
const title = requiredLimitedString(body, 'title', 120);
const message = limitedNullableString(body.message ?? body.description, 1000, 'message');
const followupType = optionalChoice(body.followupType, STUDENT_FOLLOWUP_TYPES, 'sales');
const priority = optionalChoice(body.priority, STUDENT_FOLLOWUP_PRIORITIES, 'normal');
const assignedToUserId = nullableString(body.assignedToUserId);
const dueAt = optionalTimestampString(body.dueAt, 'dueAt');
const metadata = objectValue(body.metadata);
const requestId = randomUUID();
const idempotencyInput = limitedNullableString(body.idempotencyKey, 160, 'idempotencyKey');
const includePhone = canSeeStudentPhone(auth);
const result = await transaction(async client => {
const config = await loadCrmPushConfig(client, auth.tenantId);
if (classId) await ensureTenantClass(client, auth.tenantId, classId);
await ensureUserTenantMembership(client, auth.tenantId, assignedToUserId, CRM_ASSIGNABLE_ROLES, 'ASSIGNEE_NOT_FOUND');
const assignee = await loadCrmAssigneePayload(client, auth.tenantId, assignedToUserId);
const classPayload = await loadCrmClassPayload(client, auth.tenantId, classId);
const delaySeconds = Math.max(0, Number(config.delaySec ?? 60));
const items: unknown[] = [];
const errors: unknown[] = [];
for (let index = 0; index < studentUserIds.length; index += 1) {
const studentUserId = studentUserIds[index];
try {
await ensureStudentInScope(auth, studentUserId);
await ensureStudentInClass(client, auth.tenantId, studentUserId, classId);
const student = await loadStudentCrmPayload(client, auth, studentUserId, includePhone);
const idempotencyKey = crmPushIdempotencyKey(idempotencyInput, requestId, studentUserId);
const followupMetadata = {
...metadata,
crmPush: {
requestId,
idempotencyKey,
source,
},
};
let followup = (await client.query<{
id: string;
studentUserId: string;
assignedToUserId: string | null;
classId: string | null;
title: string;
dueAt: string | null;
createdAt: string;
}>(
`
select id, student_user_id as "studentUserId",
assigned_to_user_id as "assignedToUserId", class_id as "classId",
title, due_at as "dueAt", created_at as "createdAt"
from public.tenant_student_followups
where tenant_id = $1
and student_user_id = $2::uuid
and metadata->'crmPush'->>'idempotencyKey' = $3
limit 1
`,
[auth.tenantId, studentUserId, idempotencyKey],
)).rows[0];
if (!followup) {
followup = (await client.query(
`
insert into public.tenant_student_followups (
tenant_id, student_user_id, assigned_to_user_id, class_id,
title, description, followup_type, priority, status, due_at,
metadata, created_by, updated_by
)
values (
$1, $2::uuid, $3::uuid, $4::uuid,
$5, $6, $7, $8, 'open', $9::timestamptz,
$10::jsonb, $11, $11
)
returning id, student_user_id as "studentUserId",
assigned_to_user_id as "assignedToUserId", class_id as "classId",
title, due_at as "dueAt", created_at as "createdAt"
`,
[
auth.tenantId,
studentUserId,
assignedToUserId,
classId,
title,
message,
followupType,
priority,
dueAt,
JSON.stringify(followupMetadata),
auth.userId,
],
)).rows[0];
}
const payload = {
eventType: 'student.crm_push',
formName: config.formName || '刷题题库',
examType: config.examType || '成人本科',
source,
title,
message,
student,
assignee,
class: classPayload,
followup: {
id: followup.id,
title: followup.title,
followupType,
priority,
dueAt: followup.dueAt,
},
actor: {
userId: auth.userId,
role: auth.role,
},
metadata,
};
const queued = await client.query<{
id: string;
status: string;
scheduledAt: string | null;
idempotencyKey: string;
}>(
`
insert into public.crm_webhook_queue (
tenant_id, record_id, status, scheduled_at, next_attempt_at, lead_id,
source, payload, idempotency_key, target_url
)
values ($1, $2, 'pending', now() + ($3::int * interval '1 second'), null, null, $4, $5::jsonb, $6, $7)
on conflict (tenant_id, idempotency_key) where idempotency_key is not null
do update set status = case
when public.crm_webhook_queue.status = 'sent' then public.crm_webhook_queue.status
else 'pending'
end,
scheduled_at = excluded.scheduled_at,
next_attempt_at = null,
payload = excluded.payload,
target_url = excluded.target_url,
updated_at = now()
returning id, status, scheduled_at as "scheduledAt", idempotency_key as "idempotencyKey"
`,
[
auth.tenantId,
studentUserId,
delaySeconds,
'tenant.student.crm_push',
JSON.stringify(payload),
idempotencyKey,
config.url,
],
);
items.push({
index,
studentUserId,
followupId: followup.id,
queueId: queued.rows[0].id,
queueStatus: queued.rows[0].status,
scheduledAt: queued.rows[0].scheduledAt,
});
} catch (error) {
errors.push({
index,
studentUserId,
code: error instanceof HttpError ? error.code : 'STUDENT_CRM_PUSH_ITEM_FAILED',
message: error instanceof Error ? error.message : 'Student CRM push item failed',
});
}
}
await recordAudit(client, auth, 'tenant.students.crm_pushed', 'crm_webhook_queue', null, {
requestId,
total: studentUserIds.length,
successCount: items.length,
errorCount: errors.length,
source,
followupType,
priority,
assignedToUserId,
classId,
});
return {
requestId,
total: studentUserIds.length,
successCount: items.length,
errorCount: errors.length,
items,
errors,
};
});
return result;
}

View File

@@ -10,6 +10,7 @@ import {
tenantStudentNotesRoute, tenantStudentNotesRoute,
tenantStudentsRoute, tenantStudentsRoute,
tenantTeachersRoute, tenantTeachersRoute,
pushTenantStudentsToCrmRoute,
updateTenantStudentStatusRoute, updateTenantStudentStatusRoute,
upsertTenantStudentFollowupRoute, upsertTenantStudentFollowupRoute,
upsertTenantStudentNoteRoute, upsertTenantStudentNoteRoute,
@@ -97,6 +98,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [
['PUT', '/api/tenant-admin/students', upsertTenantStudentRoute], ['PUT', '/api/tenant-admin/students', upsertTenantStudentRoute],
['POST', '/api/tenant-admin/students/bulk-upsert', bulkUpsertTenantStudentsRoute], ['POST', '/api/tenant-admin/students/bulk-upsert', bulkUpsertTenantStudentsRoute],
['POST', '/api/tenant-admin/students/status', updateTenantStudentStatusRoute], ['POST', '/api/tenant-admin/students/status', updateTenantStudentStatusRoute],
['POST', '/api/tenant-admin/students/crm-push', pushTenantStudentsToCrmRoute],
['GET', '/api/tenant-admin/students/notes', tenantStudentNotesRoute], ['GET', '/api/tenant-admin/students/notes', tenantStudentNotesRoute],
['PUT', '/api/tenant-admin/students/notes', upsertTenantStudentNoteRoute], ['PUT', '/api/tenant-admin/students/notes', upsertTenantStudentNoteRoute],
['GET', '/api/tenant-admin/students/followups', tenantStudentFollowupsRoute], ['GET', '/api/tenant-admin/students/followups', tenantStudentFollowupsRoute],

View File

@@ -9,6 +9,8 @@ import {
loadTenantStudentNotes, loadTenantStudentNotes,
loadTenantStudents, loadTenantStudents,
loadTenantTeachers, loadTenantTeachers,
loadTenantMembers,
pushTenantStudentsToCrm,
updateTenantStudentStatus, updateTenantStudentStatus,
upsertTenantStudent, upsertTenantStudent,
upsertTenantStudentFollowup, upsertTenantStudentFollowup,
@@ -19,7 +21,9 @@ import {
type TenantStudentInput, type TenantStudentInput,
type TenantStudentItem, type TenantStudentItem,
type TenantStudentNoteItem, type TenantStudentNoteItem,
type TenantStudentCrmPushInput,
type TenantTeacherItem, type TenantTeacherItem,
type TenantMemberItem,
} from '@/services/tenantAdmin'; } from '@/services/tenantAdmin';
import '../admin.css'; import '../admin.css';
@@ -28,6 +32,7 @@ const noteTypes = ['general', 'learning', 'service', 'sales', 'risk', 'follow_up
const followupTypes = ['learning', 'service', 'sales', 'renewal', 'risk', 'custom']; const followupTypes = ['learning', 'service', 'sales', 'renewal', 'risk', 'custom'];
const followupPriorities = ['low', 'normal', 'high', 'urgent']; const followupPriorities = ['low', 'normal', 'high', 'urgent'];
const followupStatuses = ['open', 'in_progress', 'done', 'cancelled']; const followupStatuses = ['open', 'in_progress', 'done', 'cancelled'];
const crmAssignableRoles = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'];
function emptyStudentForm(): TenantStudentInput { function emptyStudentForm(): TenantStudentInput {
return { return {
@@ -74,6 +79,13 @@ function resultSummary(result: BulkOperationResult | null) {
return `总数 ${result.total || 0} · 成功 ${result.successCount || 0} · 失败 ${result.errorCount || 0}`; return `总数 ${result.total || 0} · 成功 ${result.successCount || 0} · 失败 ${result.errorCount || 0}`;
} }
function parseIdRows(text: string) {
return Array.from(new Set(text
.split(/\r?\n|,||\s+/)
.map(item => item.trim())
.filter(Boolean)));
}
function studentTitle(item: TenantStudentItem | null) { function studentTitle(item: TenantStudentItem | null) {
if (!item) return '未选择学生'; if (!item) return '未选择学生';
return item.name || item.phone || item.email || item.username || item.userId; return item.name || item.phone || item.email || item.username || item.userId;
@@ -84,6 +96,7 @@ export default function TenantStudentsPage() {
const [classes, setClasses] = useState<TenantClassItem[]>([]); const [classes, setClasses] = useState<TenantClassItem[]>([]);
const [students, setStudents] = useState<TenantStudentItem[]>([]); const [students, setStudents] = useState<TenantStudentItem[]>([]);
const [teachers, setTeachers] = useState<TenantTeacherItem[]>([]); const [teachers, setTeachers] = useState<TenantTeacherItem[]>([]);
const [crmAssignees, setCrmAssignees] = useState<TenantMemberItem[]>([]);
const [notes, setNotes] = useState<TenantStudentNoteItem[]>([]); const [notes, setNotes] = useState<TenantStudentNoteItem[]>([]);
const [followups, setFollowups] = useState<TenantStudentFollowupItem[]>([]); const [followups, setFollowups] = useState<TenantStudentFollowupItem[]>([]);
const [selectedClassId, setSelectedClassId] = useState(''); const [selectedClassId, setSelectedClassId] = useState('');
@@ -94,8 +107,21 @@ export default function TenantStudentsPage() {
const [assignText, setAssignText] = useState(''); const [assignText, setAssignText] = useState('');
const [noteForm, setNoteForm] = useState({ noteType: 'learning', content: '', visibility: 'tenant_staff', isPinned: false }); const [noteForm, setNoteForm] = useState({ noteType: 'learning', content: '', visibility: 'tenant_staff', isPinned: false });
const [followupForm, setFollowupForm] = useState({ title: '', description: '', followupType: 'learning', priority: 'normal', status: 'open', dueAt: '', assignedToUserId: '', classId: '' }); const [followupForm, setFollowupForm] = useState({ title: '', description: '', followupType: 'learning', priority: 'normal', status: 'open', dueAt: '', assignedToUserId: '', classId: '' });
const [crmPushText, setCrmPushText] = useState('');
const [crmPushForm, setCrmPushForm] = useState<TenantStudentCrmPushInput>({
studentUserIds: [],
source: 'manual_batch',
title: '',
message: '',
followupType: 'sales',
priority: 'normal',
assignedToUserId: '',
classId: '',
dueAt: '',
});
const [bulkResult, setBulkResult] = useState<BulkOperationResult | null>(null); const [bulkResult, setBulkResult] = useState<BulkOperationResult | null>(null);
const [assignResult, setAssignResult] = useState<BulkOperationResult | null>(null); const [assignResult, setAssignResult] = useState<BulkOperationResult | null>(null);
const [crmPushResult, setCrmPushResult] = useState<BulkOperationResult | null>(null);
const [scoped, setScoped] = useState(false); const [scoped, setScoped] = useState(false);
const [busy, setBusy] = useState(''); const [busy, setBusy] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -110,6 +136,7 @@ export default function TenantStudentsPage() {
Promise.all([ Promise.all([
loadTenantClasses(80).catch(() => ({ items: [] })), loadTenantClasses(80).catch(() => ({ items: [] })),
loadTenantTeachers({ limit: 80 }).catch(() => ({ items: [] })), loadTenantTeachers({ limit: 80 }).catch(() => ({ items: [] })),
loadTenantMembers({ status: 'active', limit: 80 }).catch(() => ({ items: [] })),
loadTenantStudents({ loadTenantStudents({
classId: nextClassId || undefined, classId: nextClassId || undefined,
keyword: nextKeyword || undefined, keyword: nextKeyword || undefined,
@@ -117,9 +144,10 @@ export default function TenantStudentsPage() {
limit: 80, limit: 80,
}), }),
loadTenantStudentFollowups({ status: 'open', limit: 30 }).catch(() => ({ items: [] })), loadTenantStudentFollowups({ status: 'open', limit: 30 }).catch(() => ({ items: [] })),
]).then(([classPayload, teacherPayload, studentPayload, followupPayload]) => { ]).then(([classPayload, teacherPayload, memberPayload, studentPayload, followupPayload]) => {
setClasses(classPayload.items || []); setClasses(classPayload.items || []);
setTeachers(teacherPayload.items || []); setTeachers(teacherPayload.items || []);
setCrmAssignees((memberPayload.items || []).filter(item => crmAssignableRoles.includes(String(item.role || ''))));
setStudents(studentPayload.items || []); setStudents(studentPayload.items || []);
setScoped(studentPayload.scoped === true); setScoped(studentPayload.scoped === true);
setFollowups(followupPayload.items || []); setFollowups(followupPayload.items || []);
@@ -155,6 +183,12 @@ export default function TenantStudentsPage() {
status: item.status || 'active', status: item.status || 'active',
}); });
setFollowupForm(prev => ({ ...prev, studentUserId: item.userId, classId: selectedClassId || String((item.classes || [])[0]?.classId || '') } as typeof prev)); setFollowupForm(prev => ({ ...prev, studentUserId: item.userId, classId: selectedClassId || String((item.classes || [])[0]?.classId || '') } as typeof prev));
setCrmPushText(item.userId);
setCrmPushForm(prev => ({
...prev,
studentUserIds: [item.userId],
classId: selectedClassId || String((item.classes || [])[0]?.classId || ''),
}));
setError(''); setError('');
try { try {
const [notePayload, followupPayload] = await Promise.all([ const [notePayload, followupPayload] = await Promise.all([
@@ -259,6 +293,40 @@ export default function TenantStudentsPage() {
} }
} }
async function submitCrmPush() {
const studentUserIds = parseIdRows(crmPushText);
if (!studentUserIds.length) {
Taro.showToast({ title: '请填写学生ID', icon: 'none' });
return;
}
if (!crmPushForm.title?.trim()) {
Taro.showToast({ title: '请填写任务标题', icon: 'none' });
return;
}
setBusy('crmPush');
setError('');
try {
const result = await pushTenantStudentsToCrm({
...crmPushForm,
studentUserIds,
title: crmPushForm.title.trim(),
message: crmPushForm.message?.trim() || null,
assignedToUserId: crmPushForm.assignedToUserId || null,
classId: crmPushForm.classId || selectedClassId || null,
dueAt: crmPushForm.dueAt || null,
metadata: { source: 'taro-tenant-admin' },
idempotencyKey: `taro-${Date.now()}`,
});
setCrmPushResult(result);
Taro.showToast({ title: 'CRM推送已入队', icon: result.errorCount ? 'none' : 'success' });
reload();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'CRM推送失败');
} finally {
setBusy('');
}
}
async function saveNote() { async function saveNote() {
if (!selectedStudent) { if (!selectedStudent) {
Taro.showToast({ title: '请选择学生', icon: 'none' }); Taro.showToast({ title: '请选择学生', icon: 'none' });
@@ -470,6 +538,46 @@ export default function TenantStudentsPage() {
{!students.length ? <View className='admin-empty'></View> : null} {!students.length ? <View className='admin-empty'></View> : null}
</View> </View>
<View className='admin-section'>
<Text className='admin-section-title'>CRM </Text>
<View className='admin-form-grid'>
<Input className='admin-input' placeholder='跟进标题' value={crmPushForm.title} onInput={event => setCrmPushForm(prev => ({ ...prev, title: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='到期时间,例如 2026-07-01T10:00:00.000Z' value={crmPushForm.dueAt || ''} onInput={event => setCrmPushForm(prev => ({ ...prev, dueAt: String(event.detail.value || '') }))} />
</View>
<Textarea
className='admin-textarea'
placeholder='每行一个学生 userId'
value={crmPushText}
onInput={event => setCrmPushText(String(event.detail.value || ''))}
/>
<Textarea
className='admin-textarea'
placeholder='推送说明'
value={crmPushForm.message || ''}
onInput={event => setCrmPushForm(prev => ({ ...prev, message: String(event.detail.value || '') }))}
/>
<View className='admin-actions compact'>
{followupTypes.map(item => <Button key={item} className={`admin-button ${crmPushForm.followupType === item ? 'active' : ''}`} onClick={() => setCrmPushForm(prev => ({ ...prev, followupType: item }))}>{item}</Button>)}
</View>
<View className='admin-actions compact'>
{followupPriorities.map(item => <Button key={item} className={`admin-button ${crmPushForm.priority === item ? 'active' : ''}`} onClick={() => setCrmPushForm(prev => ({ ...prev, priority: item }))}>{item}</Button>)}
</View>
<View className='admin-actions compact'>
<Button className={`admin-button ${crmPushForm.assignedToUserId ? '' : 'active'}`} onClick={() => setCrmPushForm(prev => ({ ...prev, assignedToUserId: '' }))}></Button>
{crmAssignees.slice(0, 12).map(item => (
<Button key={item.userId} className={`admin-button ${crmPushForm.assignedToUserId === item.userId ? 'active' : ''}`} onClick={() => setCrmPushForm(prev => ({ ...prev, assignedToUserId: item.userId }))}>{item.name || item.phone || item.username || '成员'}</Button>
))}
</View>
<View className='admin-actions compact'>
<Button className='admin-button primary' loading={busy === 'crmPush'} onClick={submitCrmPush}></Button>
{selectedStudent ? <Button className='admin-button' onClick={() => setCrmPushText(selectedStudent.userId)}></Button> : null}
{crmPushResult ? <Text className='admin-row-meta'>{resultSummary(crmPushResult)}</Text> : null}
</View>
{crmPushResult?.errors?.slice(0, 3).map(item => (
<Text className='admin-row-meta break-line' key={`${item.index}:${item.code}`}> {Number(item.index || 0) + 1} · {item.code} · {item.message}</Text>
))}
</View>
<View className='admin-section'> <View className='admin-section'>
<Text className='admin-section-title'></Text> <Text className='admin-section-title'></Text>
<View className='admin-row'> <View className='admin-row'>

View File

@@ -124,6 +124,20 @@ export interface TenantStudentFollowupItem {
updatedAt?: string; updatedAt?: string;
} }
export interface TenantStudentCrmPushInput {
studentUserIds: string[];
source?: string;
title: string;
message?: string | null;
followupType?: string;
priority?: string;
assignedToUserId?: string | null;
classId?: string | null;
dueAt?: string | null;
metadata?: Record<string, unknown>;
idempotencyKey?: string;
}
export interface ImportJobItem { export interface ImportJobItem {
id: string; id: string;
importType?: string; importType?: string;
@@ -985,6 +999,13 @@ export async function updateTenantStudentStatus(input: { userId: string; status:
}); });
} }
export async function pushTenantStudentsToCrm(input: TenantStudentCrmPushInput) {
return apiRequest<BulkOperationResult & { requestId?: string }>('/api/tenant-admin/students/crm-push', {
method: 'POST',
body: input,
});
}
export async function bulkAssignTenantClassMembers(input: { export async function bulkAssignTenantClassMembers(input: {
classId: string; classId: string;
assignments: Array<{ assignments: Array<{

View File

@@ -145,6 +145,64 @@ function compactLeadPayload(task: CrmQueueRow, configRow: CrmConfigRow | null) {
}; };
} }
function arrayValue(value: unknown) {
return Array.isArray(value) ? value : [];
}
function compactStudentCrmPushPayload(task: CrmQueueRow, configRow: CrmConfigRow | null) {
const payload = objectValue(task.payload);
const student = objectValue(payload.student);
const assignee = objectValue(payload.assignee);
const classInfo = objectValue(payload.class);
const followup = objectValue(payload.followup);
const classes = arrayValue(student.classes).slice(0, 5).map(item => {
const classItem = objectValue(item);
return {
classId: stringValue(classItem.classId),
className: stringValue(classItem.className),
classCode: stringValue(classItem.classCode),
};
});
return {
formName: stringValue(payload.formName, configRow?.formName || '刷题题库'),
examType: stringValue(payload.examType, configRow?.examType || '成人本科'),
source: stringValue(payload.source, task.source || 'manual_batch'),
title: stringValue(payload.title, '学生跟进提醒'),
message: stringValue(payload.message),
student: {
id: stringValue(student.userId) || stringValue(student.id, task.recordId || ''),
username: stringValue(student.username),
name: stringValue(student.name) || stringValue(student.username) || '未填写',
phone: stringValue(student.phone),
email: stringValue(student.email),
avatarPreset: stringValue(student.avatarPreset, 'male'),
regionName: stringValue(student.regionName),
selectedSchoolName: stringValue(student.selectedSchoolName),
selectedMajorName: stringValue(student.selectedMajorName),
classes,
},
assignee: stringValue(assignee.userId) || stringValue(assignee.id) ? {
id: stringValue(assignee.userId) || stringValue(assignee.id),
name: stringValue(assignee.name) || stringValue(assignee.username),
role: stringValue(assignee.role),
} : null,
class: stringValue(classInfo.classId) ? {
classId: stringValue(classInfo.classId),
className: stringValue(classInfo.className),
classCode: stringValue(classInfo.classCode),
} : null,
followup: {
id: stringValue(followup.id),
title: stringValue(followup.title) || stringValue(payload.title),
followupType: stringValue(followup.followupType),
priority: stringValue(followup.priority),
dueAt: stringValue(followup.dueAt),
},
metadata: objectValue(payload.metadata),
};
}
function leadMarkdown(task: CrmQueueRow, configRow: CrmConfigRow | null) { function leadMarkdown(task: CrmQueueRow, configRow: CrmConfigRow | null) {
const payload = compactLeadPayload(task, configRow); const payload = compactLeadPayload(task, configRow);
const student = payload.student; const student = payload.student;
@@ -162,6 +220,36 @@ function leadMarkdown(task: CrmQueueRow, configRow: CrmConfigRow | null) {
return lines.join('\n'); return lines.join('\n');
} }
function studentCrmPushMarkdown(task: CrmQueueRow, configRow: CrmConfigRow | null) {
const payload = compactStudentCrmPushPayload(task, configRow);
const student = payload.student;
const target = [student.regionName, student.selectedSchoolName, student.selectedMajorName].filter(Boolean).join(' / ');
const classes = student.classes
.map(item => item.className || item.classCode)
.filter(Boolean)
.join('、');
const lines = [
`### ${payload.formName}学生跟进`,
`- 考试类型:${payload.examType}`,
`- 来源:${payload.source}`,
`- 任务:${payload.title}`,
`- 学生:${student.name}`,
`- 手机:${student.phone || '未填写'}`,
`- 意向:${target || '未填写'}`,
`- 班级:${payload.class?.className || classes || '无'}`,
`- 指派:${payload.assignee?.name || payload.assignee?.id || '未指派'}`,
`- 优先级:${payload.followup.priority || 'normal'}`,
`- 到期时间:${payload.followup.dueAt || '无'}`,
`- 跟进ID${payload.followup.id || '无'}`,
];
if (payload.message) lines.splice(4, 0, `- 说明:${payload.message}`);
return lines.join('\n');
}
function taskEventType(task: CrmQueueRow) {
return stringValue(objectValue(task.payload).eventType, 'lead.created');
}
function dingtalkSign(secret: string): Record<string, string> { function dingtalkSign(secret: string): Record<string, string> {
if (!secret) return {}; if (!secret) return {};
const timestamp = String(Date.now()); const timestamp = String(Date.now());
@@ -193,7 +281,10 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
const target = validateWebhookUrl(task.targetUrl || configRow?.url || ''); const target = validateWebhookUrl(task.targetUrl || configRow?.url || '');
const provider = providerFrom(configRow, task); const provider = providerFrom(configRow, task);
const secretValue = secretText(secret, ['secret', 'signSecret', 'webhookSecret']); const secretValue = secretText(secret, ['secret', 'signSecret', 'webhookSecret']);
const markdown = leadMarkdown(task, configRow); const eventType = taskEventType(task);
const isStudentPush = eventType === 'student.crm_push';
const markdown = isStudentPush ? studentCrmPushMarkdown(task, configRow) : leadMarkdown(task, configRow);
const title = isStudentPush ? '学生跟进提醒' : '新客资提醒';
const headers = { 'content-type': 'application/json' }; const headers = { 'content-type': 'application/json' };
if (provider === 'dingtalk') { if (provider === 'dingtalk') {
@@ -204,7 +295,7 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
body: { body: {
msgtype: 'markdown', msgtype: 'markdown',
markdown: { markdown: {
title: '新客资提醒', title,
text: markdown, text: markdown,
}, },
}, },
@@ -221,7 +312,7 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
...feishuSign(secretValue), ...feishuSign(secretValue),
card: { card: {
config: { wide_screen_mode: true }, config: { wide_screen_mode: true },
header: { title: { tag: 'plain_text', content: '新客资提醒' }, template: 'blue' }, header: { title: { tag: 'plain_text', content: title }, template: isStudentPush ? 'wathet' : 'blue' },
elements: [{ tag: 'markdown', content: markdown }], elements: [{ tag: 'markdown', content: markdown }],
}, },
}, },
@@ -244,7 +335,10 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
provider, provider,
url: target.toString(), url: target.toString(),
headers, headers,
body: { body: isStudentPush ? {
event: 'student.crm_push',
data: compactStudentCrmPushPayload(task, configRow),
} : {
event: 'lead.created', event: 'lead.created',
data: compactLeadPayload(task, configRow), data: compactLeadPayload(task, configRow),
}, },

View File

@@ -144,7 +144,7 @@
| 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches``activation-codes` | | 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches``activation-codes` |
| 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members``permissions``role-templates``audit-logs` | | 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members``permissions``role-templates``audit-logs` |
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes``classes/members``students``teachers`,支持班级范围权限和审计 | | 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes``classes/members``students``teachers`,支持班级范围权限和审计 |
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups`;支持逐行结果、限量、防跨租户和教师范围校验;学生导入/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/crm-push`;支持逐行结果、限量、防跨租户和教师范围校验;批量 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` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 | | 用户站内通知查看 | 可联调 | `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 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 | | 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/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 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 | | 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
@@ -162,8 +162,8 @@
| 手工补绑 | 可联调 | 需要 `referral:write` | | 手工补绑 | 可联调 | 需要 `referral:write` |
| 销售统计/客户列表/团队 | 可联调 | `/api/referral/sales-*``team` | | 销售统计/客户列表/团队 | 可联调 | `/api/referral/sales-*``team` |
| CRM 配置/队列 | 可联调 | `/api/crm/config``/api/crm/queue`CRM 配置支持 `none/direct/round_robin/referrer` 客资跟进分配策略、租户内候选成员校验、轮询游标、分配审计,客资首绑成功后会写入 `assignedToUserId` 并进入队列 payload | | CRM 配置/队列 | 可联调 | `/api/crm/config``/api/crm/queue`CRM 配置支持 `none/direct/round_robin/referrer` 客资跟进分配策略、租户内候选成员校验、轮询游标、分配审计,客资首绑成功后会写入 `assignedToUserId` 并进入队列 payload |
| CRM webhook worker | 可联调 | `apps/worker` 已支持 generic webhook、钉钉、飞书、企微群机器人消息体/签名、到期任务消费、失败退避重试、最终失败、discarded 和 `crm_webhook_log` | | CRM webhook worker | 可联调 | `apps/worker` 已支持 generic webhook、钉钉、飞书、企微群机器人消息体/签名、`lead.created` 客资首绑事件和 `student.crm_push` 学生运营跟进事件、到期任务消费、失败退避重试、最终失败、discarded 和 `crm_webhook_log` |
| CRM 增强 | 待补齐 | 富卡片模板、失败告警、死信运营后台、批量 CRM 推送和更细销售转化看板 | | CRM 增强 | 待补齐 | 富卡片模板、失败告警、死信运营后台、跟进效果统计和更细销售转化看板 |
| 分佣结算基础闭环 | 可联调 | `/api/commission/settings``member-rate``summary``orders``settlements``settlements/generate``settlements/status``settlements/export``settlements/proofs`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、CSV/JSON 导出、打款凭证登记/复核、销售/代理本人范围和租户隔离 | | 分佣结算基础闭环 | 可联调 | `/api/commission/settings``member-rate``summary``orders``settlements``settlements/generate``settlements/status``settlements/export``settlements/proofs`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、CSV/JSON 导出、打款凭证登记/复核、销售/代理本人范围和租户隔离 |
| 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款 provider、发票管理、批量凭证上传、异常调整单和分佣看板 | | 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款 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 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝、敏感字段脱敏和审计记录存在。 - 平台后台租户运营第一版已补齐:`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_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
- 班级与学生范围权限已落库:`tenant_classes``tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 班级与学生范围权限已落库:`tenant_classes``tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
- 学生运营管理已落库:`tenant_student_notes``tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验 - 学生运营管理已落库:`tenant_student_notes``tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复和学生批量 CRM 推送也已接入权限校验、范围校验和集成测试
- 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转和 7/30/90 天只读反馈运营报表;`user_score_events` 支持每日签到积分流水、租户配置化连续签到奖励和反馈奖励幂等;`badges/user_badges` 支持租户后台勋章维护、手动发放、重复发放幂等和学生个人中心展示;`user_notifications` 支持反馈处理、反馈奖励、勋章发放和积分兑换的学生站内通知Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接反馈运营摘要、积分任务/每日签到奖励/兑换操作台和用户通知查看第一版。 - 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转和 7/30/90 天只读反馈运营报表;`user_score_events` 支持每日签到积分流水、租户配置化连续签到奖励和反馈奖励幂等;`badges/user_badges` 支持租户后台勋章维护、手动发放、重复发放幂等和学生个人中心展示;`user_notifications` 支持反馈处理、反馈奖励、勋章发放和积分兑换的学生站内通知Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接反馈运营摘要、积分任务/每日签到奖励/兑换操作台和用户通知查看第一版。
- 旧题库排行榜主链路已补齐但租户默认关闭:`GET /api/learning/leaderboard` 只有在 `feature_flags.enableLeaderboard=true` 时可用,支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。 - 旧题库排行榜主链路已补齐但租户默认关闭:`GET /api/learning/leaderboard` 只有在 `feature_flags.enableLeaderboard=true` 时可用,支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。
- `learning` 已接入商用访问控制免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `learning` 已接入商用访问控制免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
@@ -30,7 +30,7 @@
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。 - 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
- 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。 - 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。
- 已新增 `npm run test:api`,自动 seed、构建、启动临时 API并断言核心学生端接口、内容导航/组卷、租户隔离、资源权限和题目导入。 - 已新增 `npm run test:api`,自动 seed、构建、启动临时 API并断言核心学生端接口、内容导航/组卷、租户隔离、资源权限和题目导入。
- 已新增 `apps/worker``npm run test:worker:crm`,用于消费 CRM webhook 队列,验证本地 fake webhook、队列状态、日志和密钥不泄露。 - 已新增 `apps/worker``npm run test:worker:crm`,用于消费 CRM webhook 队列,验证本地 fake webhook、队列状态、日志和密钥不泄露worker 已支持 `lead.created` 客资首绑事件和 `student.crm_push` 学生运营跟进事件
- 已新增 commerce worker 和 `npm run test:worker:commerce`,用于补偿查询微信/支付宝支付、处理中退款和漏通知场景;支付成功会幂等更新订单/支付并开通权益,退款成功会幂等更新退款/订单/支付并在全额退款时撤销订单权益,测试覆盖密钥不泄露和重复执行不重复开通。 - 已新增 commerce worker 和 `npm run test:worker:commerce`,用于补偿查询微信/支付宝支付、处理中退款和漏通知场景;支付成功会幂等更新订单/支付并开通权益,退款成功会幂等更新退款/订单/支付并在全额退款时撤销订单权益,测试覆盖密钥不泄露和重复执行不重复开通。
- 已新增 platform-billing worker 和 `npm run test:worker:platform-billing`,用于自动处理即将到期且未开票的 SaaS 订阅worker 使用订阅行锁和账单查重保证幂等,自动生成 `tenant_invoices/tenant_invoice_items` 并写 `platform.invoice.subscription_auto_created` 审计。 - 已新增 platform-billing worker 和 `npm run test:worker:platform-billing`,用于自动处理即将到期且未开票的 SaaS 订阅worker 使用订阅行锁和账单查重保证幂等,自动生成 `tenant_invoices/tenant_invoice_items` 并写 `platform.invoice.subscription_auto_created` 审计。
- 已新增 platform-dunning worker 和 `npm run test:worker:platform-dunning`,用于扫描已过 `due_date` 且未结清的 SaaS 服务费账单worker 使用账单行锁和每日唯一催缴约束保证幂等,自动标记 `overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写 `platform.invoice.overdue_processed` 审计。 - 已新增 platform-dunning worker 和 `npm run test:worker:platform-dunning`,用于扫描已过 `due_date` 且未结清的 SaaS 服务费账单worker 使用账单行锁和每日唯一催缴约束保证幂等,自动标记 `overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写 `platform.invoice.overdue_processed` 审计。

View File

@@ -1,8 +1,8 @@
# CRM Webhook Worker # CRM Webhook Worker
更新时间2026-06-29 更新时间2026-06-30
`apps/worker` 是后端异步任务进程,当前首个落地任务是 CRM 客资 webhook 推送。API 只负责在客资首绑时写入 `crm_webhook_queue`worker 负责消费到期任务、签名、发送、失败重试和写入 `crm_webhook_log` `apps/worker` 是后端异步任务进程,当前 CRM 任务支持客资首绑 webhook 推送和租户后台学生批量跟进推送。API 只负责在客资首绑或学生运营动作时写入 `crm_webhook_queue`worker 负责消费到期任务、签名、发送、失败重试和写入 `crm_webhook_log`
## 运行命令 ## 运行命令
@@ -45,6 +45,7 @@ WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
PUT /api/crm/config PUT /api/crm/config
GET /api/crm/config GET /api/crm/config
GET /api/crm/queue GET /api/crm/queue
POST /api/tenant-admin/students/crm-push
``` ```
密钥必须写入 `app_private.tenant_secrets`,公共表 `crm_config.secret_ref` 只保留引用,例如: 密钥必须写入 `app_private.tenant_secrets`,公共表 `crm_config.secret_ref` 只保留引用,例如:
@@ -59,7 +60,7 @@ worker 根据队列 payload 的 `provider` 或 webhook URL 自动判断:
| provider | URL 特征 | 行为 | | provider | URL 特征 | 行为 |
| --- | --- | --- | | --- | --- | --- |
| `generic` | 默认 | POST `{ event: "lead.created", data: ... }` | | `generic` | 默认 | POST `{ event: "lead.created", data: ... }``{ event: "student.crm_push", data: ... }` |
| `dingtalk` | `dingtalk.com` | markdown 消息;按钉钉自定义机器人安全设置做 timestamp/sign | | `dingtalk` | `dingtalk.com` | markdown 消息;按钉钉自定义机器人安全设置做 timestamp/sign |
| `feishu` | `feishu.cn` / `larksuite.com` | interactive card按飞书自定义机器人签名做 timestamp/sign | | `feishu` | `feishu.cn` / `larksuite.com` | interactive card按飞书自定义机器人签名做 timestamp/sign |
| `wecom` | `qyapi.weixin.qq.com` / `work.weixin.qq.com` | 企业微信群机器人 markdown 消息 | | `wecom` | `qyapi.weixin.qq.com` / `work.weixin.qq.com` | 企业微信群机器人 markdown 消息 |
@@ -95,13 +96,14 @@ pending -> discarded
- 前端不能直接写 `crm_webhook_queue` - 前端不能直接写 `crm_webhook_queue`
- 前端不能持有 CRM webhook secret。 - 前端不能持有 CRM webhook secret。
- 学生批量推送必须走 `POST /api/tenant-admin/students/crm-push`,后端会校验 `crm:write``students:read``students:followups:write`、学生范围、班级范围和租户成员指派人。
- 学生推送 payload 只包含 `avatarPreset` 默认头像口径,不写入、不回显微信/QQ/旧库头像 URL。
- 生产环境 webhook 必须使用 HTTPS。 - 生产环境 webhook 必须使用 HTTPS。
- `WORKER_CRM_ALLOW_INSECURE_LOCALHOST=true` 只用于本地 fake webhook 测试。 - `WORKER_CRM_ALLOW_INSECURE_LOCALHOST=true` 只用于本地 fake webhook 测试。
- worker 使用数据库服务端连接读取 `app_private.tenant_secrets`,不要把 secret 复制到公共表。 - worker 使用数据库服务端连接读取 `app_private.tenant_secrets`,不要把 secret 复制到公共表。
## 后续增强 ## 后续增强
- 轮询/定向分配销售。
- 钉钉/飞书/企微富卡片模板。 - 钉钉/飞书/企微富卡片模板。
- 失败告警和死信运营后台。 - 失败告警和死信运营后台。
- 批量 CRM 推送、跟进效果统计。 - 跟进效果统计和销售转化看板

View File

@@ -94,13 +94,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/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/dashboard/index.tsx` | `tenant-admin/dashboard` |
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes``tenant-admin/teachers``tenant-admin/students``students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups` | | 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes``tenant-admin/teachers``tenant-admin/students``students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups``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/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/marketing/index.tsx` | `tenant-admin/coupons``code-batches``activation-codes``crm/config``crm/queue``commission/settings``member-rate``summary``orders``settlements``settlements/generate``settlements/status``tenant-admin/feedbacks/report``tenant-admin/point-activity-tasks``tenant-admin/point-activity-claims``tenant-admin/point-exchange-items``tenant-admin/point-exchange-orders``tenant-admin/points-risk-report``tenant-admin/user-notifications`;已接 CRM、分佣、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置与记录查看、积分风控摘要、用户通知查看第一版 |
| 财务运营 | `apps/taro/src/pages/tenant-admin/finance/index.tsx` | `commerce/refunds``commerce/refunds/status``commerce/operations/anomalies``commerce/reconciliation/batches``commerce/reconciliation/items``commerce/reconciliation/issues/create``commerce/reconciliation/issues``commerce/reconciliation/issues/status``commerce/reconciliation/provider-bills/request``commerce/reconciliation/provider-bills/jobs``commerce/adjustment-vouchers``commerce/adjustment-vouchers/status``commerce/adjustment-vouchers/report` | | 财务运营 | `apps/taro/src/pages/tenant-admin/finance/index.tsx` | `commerce/refunds``commerce/refunds/status``commerce/operations/anomalies``commerce/reconciliation/batches``commerce/reconciliation/items``commerce/reconciliation/issues/create``commerce/reconciliation/issues``commerce/reconciliation/issues/status``commerce/reconciliation/provider-bills/request``commerce/reconciliation/provider-bills/jobs``commerce/adjustment-vouchers``commerce/adjustment-vouchers/status``commerce/adjustment-vouchers/report` |
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview``domains``payment-accounts``auth-providers``theme-templates``theme``theme/preview``theme/publish``permissions``GET/PUT role-templates``POST role-templates/disable``GET/PUT members``POST members/disable` | | 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview``domains``payment-accounts``auth-providers``theme-templates``theme``theme/preview``theme/publish``permissions``GET/PUT role-templates``POST role-templates/disable``GET/PUT members``POST members/disable` |
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务完成跟进;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置、领取/兑换记录查看、积分风控只读摘要和用户通知查看第一版;财务运营页支持退款状态流、官方账单任务、对账异常、差错工单和调整凭证,且只通过后端命令层写审计与状态;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、主题素材库、真实打款 provider、发票、真实生产账单抽样验收和小程序端兼容。 当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务完成跟进和 CRM 入队推送;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置、领取/兑换记录查看、积分风控只读摘要和用户通知查看第一版;财务运营页支持退款状态流、官方账单任务、对账异常、差错工单和调整凭证,且只通过后端命令层写审计与状态;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、主题素材库、真实打款 provider、发票、真实生产账单抽样验收和小程序端兼容。
## 已落地的 Taro 平台后台页面 ## 已落地的 Taro 平台后台页面

View File

@@ -42,7 +42,7 @@
| 旧功能/组件 | 新后端状态 | 待补齐 | | 旧功能/组件 | 新后端状态 | 待补齐 |
| --- | --- | --- | | --- | --- | --- |
| 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务已实现;批量 CRM 推送补绑、学习督导自动化待补 | | 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务批量 CRM 推送已实现;补绑、学习督导自动化和跟进效果统计待补 |
| 销售/代理管理 | 部分覆盖 | referral/team/stats 已有;分佣设置、订单/激活码归因、结算单生成、审核、打款状态、CSV/JSON 导出、打款凭证登记/复核和本人/全局权限范围已覆盖;还缺真实打款 provider、发票、批量凭证上传和分佣看板 | | 销售/代理管理 | 部分覆盖 | referral/team/stats 已有;分佣设置、订单/激活码归因、结算单生成、审核、打款状态、CSV/JSON 导出、打款凭证登记/复核和本人/全局权限范围已覆盖;还缺真实打款 provider、发票、批量凭证上传和分佣看板 |
| 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 | | 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 |
| 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 | | 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 |
@@ -66,7 +66,7 @@
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 | | 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 | | 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
| 用户站内通知 | 部分覆盖 | 反馈处理、反馈奖励、勋章发放和积分兑换会写入 `user_notifications`,学生可查/标记状态租户后台可按权限查看租户内通知Taro 学生个人中心已接消息筛选/已读/归档第一版,租户营销中心已接用户通知查看第一版 | 独立消息中心增强、外部微信订阅消息/短信和批量统计待补 | | 用户站内通知 | 部分覆盖 | 反馈处理、反馈奖励、勋章发放和积分兑换会写入 `user_notifications`,学生可查/标记状态租户后台可按权限查看租户内通知Taro 学生个人中心已接消息筛选/已读/归档第一版,租户营销中心已接用户通知查看第一版 | 独立消息中心增强、外部微信订阅消息/短信和批量统计待补 |
| CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、钉钉/飞书/企微 worker、签名和重试已有富卡片模板、失败告警、死信运营台和批量推送待补 | | CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、学生批量 CRM 推送、钉钉/飞书/企微 worker、签名和重试已有富卡片模板、失败告警、死信运营台和跟进效果统计待补 |
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 | | 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 |
## 平台 SaaS 后台功能 ## 平台 SaaS 后台功能

View File

@@ -20,7 +20,7 @@
- 内容导入:题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、校验、导入、幂等、审计。 - 内容导入:题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、校验、导入、幂等、审计。
- 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。 - 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;学生写入口已拒绝头像 URL、第三方头像字段和 `primaryRole`学生头像统一走男女预设Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。 - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务、批量 CRM 推送已完成接口和集成测试;学生写入口已拒绝头像 URL、第三方头像字段和 `primaryRole`学生头像统一走男女预设Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注跟进任务和 CRM 推送第一版;后续补自动学习督导、跟进效果统计和更细导入模板体验。
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、反馈运营聚合报表、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。 - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、反馈运营聚合报表、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。学生激励以后台配置勋章自动发放为主,不默认启用排行榜竞争。后续补更细活动效果看板和更细前端运营配置体验。 - 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。学生激励以后台配置勋章自动发放为主,不默认启用排行榜竞争。后续补更细活动效果看板和更细前端运营配置体验。
- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单兑换完成或待履约会写入用户站内通知Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台、反馈运营摘要和积分风控只读摘要第一版。后续补更细活动效果看板。 - 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单兑换完成或待履约会写入用户站内通知Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台、反馈运营摘要和积分风控只读摘要第一版。后续补更细活动效果看板。
@@ -159,9 +159,9 @@
- 继续补真实打款 provider、发票管理、批量凭证上传、销售团队分佣看板和异常调整单。 - 继续补真实打款 provider、发票管理、批量凭证上传、销售团队分佣看板和异常调整单。
12. 学生运营管理 12. 学生运营管理
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注跟进任务;学生写入口拒绝头像 URL、第三方头像字段和 `primaryRole`,避免绕过预设头像与租户角色体系。 - 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注跟进任务和批量 CRM 推送;学生写入口拒绝头像 URL、第三方头像字段和 `primaryRole`,避免绕过预设头像与租户角色体系。
- 已完成 Taro 学生运营页第一版:学生创建/更新、状态禁用/恢复、批量导入、批量分班、备注、跟进任务完成跟进。 - 已完成 Taro 学生运营页第一版:学生创建/更新、状态禁用/恢复、批量导入、批量分班、备注、跟进任务完成跟进和 CRM 入队推送
- 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和更细导入模板体验。 - 继续补学习督导自动化、跟进效果统计和更细导入模板体验。
13. AI 择校推荐 13. AI 择校推荐
- 已完成 `ai_recommendation_reports` 报告台账、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON 输出、报告列表/详情接口和 Taro 学生端基础页。 - 已完成 `ai_recommendation_reports` 报告台账、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON 输出、报告列表/详情接口和 Taro 学生端基础页。

View File

@@ -1477,6 +1477,7 @@ content_entries
- 教师、班主任、助教类账号进入租户后台时,学生列表以 `GET /api/tenant-admin/students` 返回的 `scoped``items` 为准;前端不要自行用本地班级 ID 放大查询范围。 - 教师、班主任、助教类账号进入租户后台时,学生列表以 `GET /api/tenant-admin/students` 返回的 `scoped``items` 为准;前端不要自行用本地班级 ID 放大查询范围。
- 学生手机号、订单金额、客资归属等敏感字段按 `fieldPermissions` 控制显示;字段被后端返回为 `null` 时前端展示脱敏占位,不要从其它接口补取。 - 学生手机号、订单金额、客资归属等敏感字段按 `fieldPermissions` 控制显示;字段被后端返回为 `null` 时前端展示脱敏占位,不要从其它接口补取。
- 学生批量导入和批量分班接口会返回 `total/successCount/errorCount/results`,前端必须展示逐行错误,不要在浏览器端静默丢弃失败行。学生导入模板不要提供头像 URL 或 `primaryRole` 字段;后端会拒绝这类字段,学生头像统一由个人中心 `avatarPreset` 控制。 - 学生批量导入和批量分班接口会返回 `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`
- 教师可以为范围内学生创建备注和跟进任务,但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定;前端只按返回值渲染。 - 教师可以为范围内学生创建备注和跟进任务,但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定;前端只按返回值渲染。
- H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。 - H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。

View File

@@ -9463,6 +9463,102 @@ async function testReferralAndCrmGrowth() {
assert.deepEqual(crmConfig.item?.assignmentPool, [TENANT_SALES_USER_ID, TENANT_AGENT_USER_ID], 'CRM config should persist assignment pool'); assert.deepEqual(crmConfig.item?.assignmentPool, [TENANT_SALES_USER_ID, TENANT_AGENT_USER_ID], 'CRM config should persist assignment pool');
assert.ok(!JSON.stringify(crmConfig).includes('crm-secret-smoke'), 'CRM config response should not leak secret'); assert.ok(!JSON.stringify(crmConfig).includes('crm-secret-smoke'), 'CRM config response should not leak secret');
const studentCrmPush = await request('/api/tenant-admin/students/crm-push', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
studentUserIds: [USER_ID, SECOND_STUDENT_USER_ID],
source: 'integration_batch',
title: '集成测试批量跟进',
message: '请销售今日联系并记录转化情况',
followupType: 'sales',
priority: 'high',
assignedToUserId: TENANT_SALES_USER_ID,
dueAt: '2026-07-02T10:00:00.000Z',
idempotencyKey: 'integration-student-crm-push',
metadata: { source: 'integration-test' },
},
});
assert.equal(studentCrmPush.total, 2, 'student CRM push should report total');
assert.equal(studentCrmPush.successCount, 2, 'student CRM push should enqueue valid students');
assert.equal(studentCrmPush.errorCount, 0, 'student CRM push should not fail valid students');
assert.ok(studentCrmPush.items?.every(item => item.queueId && item.followupId), 'student CRM push should return queue and follow-up ids');
const repeatedStudentCrmPush = await request('/api/tenant-admin/students/crm-push', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
studentUserIds: [USER_ID, SECOND_STUDENT_USER_ID],
title: '集成测试批量跟进',
message: '重复提交不应重复创建队列',
followupType: 'sales',
priority: 'high',
assignedToUserId: TENANT_SALES_USER_ID,
idempotencyKey: 'integration-student-crm-push',
},
});
assert.deepEqual(
repeatedStudentCrmPush.items?.map(item => item.queueId).sort(),
studentCrmPush.items?.map(item => item.queueId).sort(),
'student CRM push idempotency should reuse queue ids',
);
assert.deepEqual(
repeatedStudentCrmPush.items?.map(item => item.followupId).sort(),
studentCrmPush.items?.map(item => item.followupId).sort(),
'student CRM push idempotency should reuse follow-up ids',
);
const studentCrmPushClassError = await request('/api/tenant-admin/students/crm-push', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
studentUserIds: [USER_ID, SECOND_STUDENT_USER_ID],
title: '集成测试班级范围跟进',
classId: ids.tenantClass,
idempotencyKey: 'integration-student-crm-push-class-scope',
},
});
assert.equal(studentCrmPushClassError.total, 2, 'student CRM class scoped push should report total');
assert.equal(studentCrmPushClassError.successCount, 1, 'student CRM class scoped push should enqueue in-class student');
assert.ok(
studentCrmPushClassError.errors?.some(item => item.studentUserId === SECOND_STUDENT_USER_ID && item.code === 'STUDENT_CLASS_REQUIRED'),
'student CRM class scoped push should reject students outside the selected class',
);
const operatorCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
userId: TENANT_OPERATOR_USER_ID,
method: 'POST',
body: {
studentUserIds: [USER_ID],
title: '不应允许普通运营推送',
},
expectStatus: 403,
});
assert.equal(operatorCrmPushDenied.code, 'TENANT_PERMISSION_REQUIRED', 'student CRM push should require crm:write permission');
const partnerStudentCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
studentUserIds: [USER_ID],
title: '不应跨租户推送',
},
expectStatus: 403,
});
assert.equal(partnerStudentCrmPushDenied.code, 'TENANT_ADMIN_REQUIRED', 'student CRM push must be tenant isolated');
const studentCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
userId: USER_ID,
method: 'POST',
body: {
studentUserIds: [USER_ID],
title: '学生端不应推送 CRM',
},
expectStatus: 403,
});
assert.equal(studentCrmPushDenied.code, 'TENANT_ADMIN_REQUIRED', 'student CRM push should reject student users');
const invalidCrmAssignment = await request('/api/crm/config', { const invalidCrmAssignment = await request('/api/crm/config', {
userId: TENANT_ADMIN_USER_ID, userId: TENANT_ADMIN_USER_ID,
method: 'PUT', method: 'PUT',
@@ -9820,6 +9916,14 @@ async function testReferralAndCrmGrowth() {
}); });
assert.ok(crmQueue.items?.some(item => item.leadId === manual.lead.item.id || item.leadId === tracked.lead.item.id), 'CRM queue should include referral lead task'); assert.ok(crmQueue.items?.some(item => item.leadId === manual.lead.item.id || item.leadId === tracked.lead.item.id), 'CRM queue should include referral lead task');
assert.ok(crmQueue.items?.some(item => item.payload?.assignee?.id === TENANT_AGENT_USER_ID), 'CRM queue payload should include assigned follow-up user'); assert.ok(crmQueue.items?.some(item => item.payload?.assignee?.id === TENANT_AGENT_USER_ID), 'CRM queue payload should include assigned follow-up user');
assert.ok(
crmQueue.items?.some(item => item.payload?.eventType === 'student.crm_push' && item.payload?.assignee?.userId === TENANT_SALES_USER_ID),
'CRM queue should include student CRM push task',
);
assert.ok(
crmQueue.items?.some(item => item.payload?.eventType === 'student.crm_push' && item.payload?.student?.avatarPreset && !item.payload?.student?.avatarUrl),
'student CRM push payload should use preset avatars only',
);
assert.ok(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret'); assert.ok(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret');
const partnerReferralDenied = await request('/api/referral/sales-stats', { const partnerReferralDenied = await request('/api/referral/sales-stats', {

View File

@@ -0,0 +1,9 @@
drop index if exists public.idx_crm_queue_tenant_record_id;
create index if not exists idx_crm_queue_tenant_record_id_lookup
on public.crm_webhook_queue(tenant_id, record_id, created_at desc)
where record_id is not null;
create index if not exists idx_crm_queue_student_push
on public.crm_webhook_queue(tenant_id, source, created_at desc)
where source = 'tenant.student.crm_push';