From 7f97b52165bd0d89e0624dc31d10fcc4fd74ce04 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 16:21:17 +0800 Subject: [PATCH] feat: add student crm push --- README.md | 6 +- apps/api/src/features/tenant-admin/classes.ts | 408 +++++++++++++++++- apps/api/src/features/tenant-admin/index.ts | 2 + .../src/pages/tenant-admin/students/index.tsx | 110 ++++- apps/taro/src/services/tenantAdmin.ts | 21 + apps/worker/src/jobs/crm.ts | 102 ++++- docs/refactor/backend-capability-status.md | 6 +- docs/refactor/backend-progress.md | 4 +- docs/refactor/crm-worker.md | 12 +- docs/refactor/frontend-handoff-index.md | 4 +- docs/refactor/legacy-feature-gap-matrix.md | 4 +- docs/refactor/next-development-todo.md | 8 +- docs/refactor/taro-frontend-integration.md | 1 + scripts/api-integration-test.js | 104 +++++ .../202606300012_crm_student_push_queue.sql | 9 + 15 files changed, 774 insertions(+), 27 deletions(-) create mode 100644 supabase/migrations/202606300012_crm_student_push_queue.sql diff --git a/README.md b/README.md index 68984118..cd4c49b8 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,14 @@ - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 - `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 -- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 +- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、学生批量 CRM 推送、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口(租户默认关闭)、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。 - 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 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 卡片和拼图包。 -- 销售/代理/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 可把高风险平台审计动作转换为内部告警并递归脱敏告警 details;platform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。 +- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略、客资队列和学生批量 CRM 跟进推送。 +- `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 可把高风险平台审计动作转换为内部告警并递归脱敏告警 details;platform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。 - 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 - 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。 diff --git a/apps/api/src/features/tenant-admin/classes.ts b/apps/api/src/features/tenant-admin/classes.ts index cc746f72..10408f94 100644 --- a/apps/api/src/features/tenant-admin/classes.ts +++ b/apps/api/src/features/tenant-admin/classes.ts @@ -1,3 +1,4 @@ +import { createHash, randomUUID } from 'node:crypto'; import type pg from 'pg'; import { HttpError, type RequestContext } from '../../core/http.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 MAX_BULK_STUDENTS = 200; 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_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) { 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 { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } @@ -80,7 +101,42 @@ function uuidArrayValue(value: unknown) { if (!Array.isArray(value)) return []; return value .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(); + 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) { @@ -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( + ` + 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>( + ` + 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>( + ` + 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>( + ` + 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) { return memberType === 'student' ? 'student' : 'teacher'; } @@ -1267,3 +1467,209 @@ export async function upsertTenantStudentFollowupRoute(ctx: RequestContext) { 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; +} diff --git a/apps/api/src/features/tenant-admin/index.ts b/apps/api/src/features/tenant-admin/index.ts index 674de5f0..95757056 100644 --- a/apps/api/src/features/tenant-admin/index.ts +++ b/apps/api/src/features/tenant-admin/index.ts @@ -10,6 +10,7 @@ import { tenantStudentNotesRoute, tenantStudentsRoute, tenantTeachersRoute, + pushTenantStudentsToCrmRoute, updateTenantStudentStatusRoute, upsertTenantStudentFollowupRoute, upsertTenantStudentNoteRoute, @@ -97,6 +98,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [ ['PUT', '/api/tenant-admin/students', upsertTenantStudentRoute], ['POST', '/api/tenant-admin/students/bulk-upsert', bulkUpsertTenantStudentsRoute], ['POST', '/api/tenant-admin/students/status', updateTenantStudentStatusRoute], + ['POST', '/api/tenant-admin/students/crm-push', pushTenantStudentsToCrmRoute], ['GET', '/api/tenant-admin/students/notes', tenantStudentNotesRoute], ['PUT', '/api/tenant-admin/students/notes', upsertTenantStudentNoteRoute], ['GET', '/api/tenant-admin/students/followups', tenantStudentFollowupsRoute], diff --git a/apps/taro/src/pages/tenant-admin/students/index.tsx b/apps/taro/src/pages/tenant-admin/students/index.tsx index a5a2aa30..76b8e7d6 100644 --- a/apps/taro/src/pages/tenant-admin/students/index.tsx +++ b/apps/taro/src/pages/tenant-admin/students/index.tsx @@ -9,6 +9,8 @@ import { loadTenantStudentNotes, loadTenantStudents, loadTenantTeachers, + loadTenantMembers, + pushTenantStudentsToCrm, updateTenantStudentStatus, upsertTenantStudent, upsertTenantStudentFollowup, @@ -19,7 +21,9 @@ import { type TenantStudentInput, type TenantStudentItem, type TenantStudentNoteItem, + type TenantStudentCrmPushInput, type TenantTeacherItem, + type TenantMemberItem, } from '@/services/tenantAdmin'; 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 followupPriorities = ['low', 'normal', 'high', 'urgent']; const followupStatuses = ['open', 'in_progress', 'done', 'cancelled']; +const crmAssignableRoles = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent']; function emptyStudentForm(): TenantStudentInput { return { @@ -74,6 +79,13 @@ function resultSummary(result: BulkOperationResult | null) { 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) { if (!item) return '未选择学生'; return item.name || item.phone || item.email || item.username || item.userId; @@ -84,6 +96,7 @@ export default function TenantStudentsPage() { const [classes, setClasses] = useState([]); const [students, setStudents] = useState([]); const [teachers, setTeachers] = useState([]); + const [crmAssignees, setCrmAssignees] = useState([]); const [notes, setNotes] = useState([]); const [followups, setFollowups] = useState([]); const [selectedClassId, setSelectedClassId] = useState(''); @@ -94,8 +107,21 @@ export default function TenantStudentsPage() { const [assignText, setAssignText] = useState(''); 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 [crmPushText, setCrmPushText] = useState(''); + const [crmPushForm, setCrmPushForm] = useState({ + studentUserIds: [], + source: 'manual_batch', + title: '', + message: '', + followupType: 'sales', + priority: 'normal', + assignedToUserId: '', + classId: '', + dueAt: '', + }); const [bulkResult, setBulkResult] = useState(null); const [assignResult, setAssignResult] = useState(null); + const [crmPushResult, setCrmPushResult] = useState(null); const [scoped, setScoped] = useState(false); const [busy, setBusy] = useState(''); const [error, setError] = useState(''); @@ -110,6 +136,7 @@ export default function TenantStudentsPage() { Promise.all([ loadTenantClasses(80).catch(() => ({ items: [] })), loadTenantTeachers({ limit: 80 }).catch(() => ({ items: [] })), + loadTenantMembers({ status: 'active', limit: 80 }).catch(() => ({ items: [] })), loadTenantStudents({ classId: nextClassId || undefined, keyword: nextKeyword || undefined, @@ -117,9 +144,10 @@ export default function TenantStudentsPage() { limit: 80, }), loadTenantStudentFollowups({ status: 'open', limit: 30 }).catch(() => ({ items: [] })), - ]).then(([classPayload, teacherPayload, studentPayload, followupPayload]) => { + ]).then(([classPayload, teacherPayload, memberPayload, studentPayload, followupPayload]) => { 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 || []); @@ -155,6 +183,12 @@ export default function TenantStudentsPage() { status: item.status || 'active', }); 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(''); try { 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() { if (!selectedStudent) { Taro.showToast({ title: '请选择学生', icon: 'none' }); @@ -470,6 +538,46 @@ export default function TenantStudentsPage() { {!students.length ? 暂无学生,或当前角色没有可见学生范围。 : null} + + CRM 批量推送 + + setCrmPushForm(prev => ({ ...prev, title: String(event.detail.value || '') }))} /> + setCrmPushForm(prev => ({ ...prev, dueAt: String(event.detail.value || '') }))} /> + +