diff --git a/README.md b/README.md index 6a335a6a..fe855064 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 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 配置、跟进分配策略和队列。 - `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。 - 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 @@ -33,7 +33,7 @@ - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF/图片预览、资源访问事件、动态水印上下文、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成。生产还要配置真实扫描服务 endpoint/token,并继续补转码/CDN 级水印、CDN 刷新和对象生命周期策略。 - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 -- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 +- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 - `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 diff --git a/apps/api/src/features/referral/routes.ts b/apps/api/src/features/referral/routes.ts index 12f7aaa4..93728047 100644 --- a/apps/api/src/features/referral/routes.ts +++ b/apps/api/src/features/referral/routes.ts @@ -11,6 +11,8 @@ type JsonBody = Record; const REFERRAL_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const EVENT_TYPES = ['enter', 'register', 'purchase', 'share', 'scan', 'manual_bind']; const TRACK_SOURCES = ['share', 'qrcode', 'timeline', 'miniapp', 'h5', 'manual', 'unknown']; +const CRM_ASSIGNMENT_MODES = ['none', 'direct', 'round_robin', 'referrer']; +const CRM_ASSIGNABLE_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent']; function nullableString(value: unknown) { return typeof value === 'string' && value.trim() ? value.trim() : null; @@ -28,6 +30,19 @@ function optionalChoice(value: unknown, allowed: string[], fallback: string) { return candidate; } +function stringArray(value: unknown) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const items: string[] = []; + for (const item of value) { + const normalized = typeof item === 'string' ? item.trim() : ''; + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + items.push(normalized); + } + return items; +} + function normalizeCode(code: string) { return code.replace(/\s+/g, '').toUpperCase(); } @@ -65,6 +80,169 @@ async function userHasTenantMembership(client: pg.PoolClient, tenantId: string, return Boolean(result.rows[0]); } +async function tenantMemberForCrmAssignment(client: pg.PoolClient, tenantId: string, userId: string) { + const result = await client.query<{ userId: string; role: string; name: string | null; username: string | null }>( + ` + select tm.user_id as "userId", tm.role, u.name, u.username + 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 + and tm.status = 'active' + and tm.role = any($3::text[]) + limit 1 + `, + [tenantId, userId, CRM_ASSIGNABLE_ROLES], + ); + return result.rows[0] || null; +} + +async function validateCrmAssignmentPool(client: pg.PoolClient, tenantId: string, pool: string[], mode: string) { + if (!pool.length) { + if (mode === 'direct' || mode === 'round_robin') { + throw new HttpError(400, 'assignmentPool is required for this CRM assignment mode', 'CRM_ASSIGNMENT_POOL_REQUIRED'); + } + return []; + } + if (pool.length > 100) { + throw new HttpError(400, 'assignmentPool cannot contain more than 100 members', 'CRM_ASSIGNMENT_POOL_TOO_LARGE'); + } + + const rows = await client.query<{ userId: string }>( + ` + select tm.user_id as "userId" + from public.tenant_memberships tm + where tm.tenant_id = $1 + and tm.user_id = any($2::uuid[]) + and tm.status = 'active' + and tm.role = any($3::text[]) + `, + [tenantId, pool, CRM_ASSIGNABLE_ROLES], + ); + const valid = new Set(rows.rows.map(row => row.userId)); + const invalid = pool.filter(userId => !valid.has(userId)); + if (invalid.length) { + throw new HttpError(400, 'CRM assignment pool contains users outside the tenant or unsupported roles', 'CRM_ASSIGNMENT_POOL_INVALID'); + } + return pool; +} + +async function crmAssignmentPoolMembers(client: pg.PoolClient, tenantId: string, pool: unknown) { + const ids = stringArray(pool); + if (!ids.length) return []; + const rows = await client.query<{ userId: string; role: string; name: string | null; username: string | null }>( + ` + with pool(user_id, ord) as ( + select value::uuid, ord + from jsonb_array_elements_text($2::jsonb) with ordinality as item(value, ord) + ) + select p.user_id as "userId", tm.role, u.name, u.username + from pool p + join public.tenant_memberships tm on tm.tenant_id = $1 + and tm.user_id = p.user_id + and tm.status = 'active' + and tm.role = any($3::text[]) + join public.platform_users u on u.id = tm.user_id + order by p.ord asc + `, + [tenantId, JSON.stringify(ids), CRM_ASSIGNABLE_ROLES], + ); + return rows.rows; +} + +async function assignReferralLeadFromCrmConfig( + client: pg.PoolClient, + input: { + tenantId: string; + leadId: string; + referrerUserId: string | null; + actorUserId?: string | null; + trigger: string; + }, +) { + const cfg = await client.query<{ + assignmentMode: string; + assignmentPool: unknown; + assignmentCursor: number; + }>( + ` + select assignment_mode as "assignmentMode", + assignment_pool as "assignmentPool", + assignment_cursor as "assignmentCursor" + from public.crm_config + where tenant_id = $1 + limit 1 + for update + `, + [input.tenantId], + ); + const config = cfg.rows[0]; + const mode = config?.assignmentMode || 'none'; + if (mode === 'none') return null; + + let assignee: { userId: string; role: string; name: string | null; username: string | null } | null = null; + if (mode === 'referrer' && input.referrerUserId) { + assignee = await tenantMemberForCrmAssignment(client, input.tenantId, input.referrerUserId); + } + if (mode === 'direct' || mode === 'round_robin') { + const members = await crmAssignmentPoolMembers(client, input.tenantId, config.assignmentPool); + if (members.length) { + const cursor = Math.max(0, Number(config.assignmentCursor || 0)); + assignee = mode === 'round_robin' ? members[cursor % members.length] : members[0]; + if (mode === 'round_robin') { + await client.query( + 'update public.crm_config set assignment_cursor = assignment_cursor + 1, updated_at = now() where tenant_id = $1', + [input.tenantId], + ); + } + } + } + if (!assignee) return null; + + const result = await client.query( + ` + update public.referral_leads + set assigned_to_user_id = $3::uuid, + assignment_mode = $4::text, + assigned_at = now(), + assigned_by = $5::uuid, + metadata = metadata || jsonb_build_object( + 'crmAssignment', + jsonb_build_object( + 'mode', $4::text, + 'trigger', $6::text, + 'assignedToUserId', $3::uuid, + 'assignedToRole', $7::text, + 'assignedAt', now() + ) + ), + updated_at = now() + where tenant_id = $1 and id = $2 + returning id, student_user_id as "studentUserId", referrer_user_id as "referrerUserId", + ref_code::text as "refCode", source, bind_type as "bindType", status, + assigned_to_user_id as "assignedToUserId", assignment_mode as "assignmentMode", + assigned_at as "assignedAt", assigned_by as "assignedBy", + bound_at as "boundAt", created_at as "createdAt", updated_at as "updatedAt" + `, + [input.tenantId, input.leadId, assignee.userId, mode, input.actorUserId || null, input.trigger, assignee.role], + ); + + await client.query( + ` + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, $2, 'crm.lead.assigned', 'referral_leads', $3, $4::jsonb) + `, + [ + input.tenantId, + input.actorUserId || null, + input.leadId, + JSON.stringify({ mode, trigger: input.trigger, assignedToUserId: assignee.userId, assignedToRole: assignee.role }), + ], + ); + + return { item: result.rows[0], assignee: { ...assignee, mode } }; +} + async function resolveReferralCode(tenantId: string, code: string) { return queryOne<{ code: string; @@ -181,12 +359,24 @@ async function enqueueCrmLead( const lead = await client.query<{ ref_code: string | null; referrer_user_id: string | null; + assigned_to_user_id: string | null; + assignment_mode: string | null; bound_at: string; + assignee_name: string | null; + assignee_username: string | null; + assignee_role: string | null; }>( ` - select ref_code::text, referrer_user_id, bound_at - from public.referral_leads - where tenant_id = $1 and id = $2 + select rl.ref_code::text, rl.referrer_user_id, rl.assigned_to_user_id, + rl.assignment_mode, rl.bound_at, + assignee.name as assignee_name, assignee.username as assignee_username, + assignee_tm.role as assignee_role + from public.referral_leads rl + left join public.platform_users assignee on assignee.id = rl.assigned_to_user_id + left join public.tenant_memberships assignee_tm on assignee_tm.tenant_id = rl.tenant_id + and assignee_tm.user_id = rl.assigned_to_user_id + and assignee_tm.status = 'active' + where rl.tenant_id = $1 and rl.id = $2 limit 1 `, [input.tenantId, input.leadId], @@ -209,6 +399,12 @@ async function enqueueCrmLead( referrerUserId: lead.rows[0]?.referrer_user_id || null, boundAt: lead.rows[0]?.bound_at || null, }, + assignee: lead.rows[0]?.assigned_to_user_id ? { + id: lead.rows[0].assigned_to_user_id, + name: lead.rows[0].assignee_name || lead.rows[0].assignee_username || null, + role: lead.rows[0].assignee_role || null, + assignmentMode: lead.rows[0].assignment_mode || null, + } : null, metadata: input.metadata || {}, }; @@ -270,12 +466,16 @@ async function bindReferralLead( id: string; referrerUserId: string | null; refCode: string | null; + assignedToUserId: string | null; + assignmentMode: string | null; + assignedAt: string | null; status: string; boundAt: string; }>( ` select id, referrer_user_id as "referrerUserId", ref_code::text as "refCode", - status, bound_at as "boundAt" + assigned_to_user_id as "assignedToUserId", assignment_mode as "assignmentMode", + assigned_at as "assignedAt", status, bound_at as "boundAt" from public.referral_leads where tenant_id = $1 and student_user_id = $2 limit 1 @@ -305,6 +505,8 @@ async function bindReferralLead( updated_at = now() returning id, student_user_id as "studentUserId", referrer_user_id as "referrerUserId", ref_code::text as "refCode", source, bind_type as "bindType", status, + assigned_to_user_id as "assignedToUserId", assignment_mode as "assignmentMode", + assigned_at as "assignedAt", assigned_by as "assignedBy", bound_at as "boundAt", created_at as "createdAt", updated_at as "updatedAt" `, [ @@ -326,7 +528,15 @@ async function bindReferralLead( ); } - return { item: result.rows[0], bound: true, protected: false }; + const assignment = await assignReferralLeadFromCrmConfig(client, { + tenantId: input.tenantId, + leadId: result.rows[0].id, + referrerUserId: result.rows[0].referrerUserId, + actorUserId: typeof input.metadata?.operatorUserId === 'string' ? input.metadata.operatorUserId : null, + trigger: input.bindType || 'first_touch', + }); + + return { item: assignment?.item || result.rows[0], assignment: assignment?.assignee || null, bound: true, protected: false }; } function crmPermission(auth: TenantAdminAuth) { @@ -561,6 +771,7 @@ export async function referralSalesStatsRoute(ctx: RequestContext) { lead_stats as ( select referrer_user_id, count(*)::int as lead_count, + count(*) filter (where assigned_to_user_id is not null)::int as assigned_lead_count, count(*) filter (where exists ( select 1 from public.orders o where o.tenant_id = rl.tenant_id @@ -587,6 +798,7 @@ export async function referralSalesStatsRoute(ctx: RequestContext) { select r.user_id as "referrerUserId", r.role, r.username, r.name, r.phone, r.code as "inviteCode", coalesce(ls.lead_count, 0) as "leadCount", + coalesce(ls.assigned_lead_count, 0) as "assignedLeadCount", coalesce(ls.paid_lead_count, 0) as "paidLeadCount", coalesce(os.paid_amount_cents, 0)::text as "paidAmountCents", coalesce(ts.track_count, 0) as "trackCount" @@ -632,6 +844,10 @@ export async function referralSalesClientsRoute(ctx: RequestContext) { source: string | null; bindType: string; status: string; + assignedToUserId: string | null; + assignedToName: string | null; + assignmentMode: string | null; + assignedAt: string | null; boundAt: string; paidAmountCents: string; lastPaidAt: string | null; @@ -640,16 +856,21 @@ export async function referralSalesClientsRoute(ctx: RequestContext) { select rl.id, rl.student_user_id as "studentUserId", u.username, u.name, u.phone, u.email::text as email, rl.referrer_user_id as "referrerUserId", rl.ref_code::text as "refCode", - rl.source, rl.bind_type as "bindType", rl.status, rl.bound_at as "boundAt", + rl.source, rl.bind_type as "bindType", rl.status, + rl.assigned_to_user_id as "assignedToUserId", + coalesce(assignee.name, assignee.username) as "assignedToName", + rl.assignment_mode as "assignmentMode", rl.assigned_at as "assignedAt", + rl.bound_at as "boundAt", coalesce(sum(o.amount_cents) filter (where o.status = 'paid'), 0)::text as "paidAmountCents", max(o.paid_at) filter (where o.status = 'paid') as "lastPaidAt" from public.referral_leads rl join public.platform_users u on u.id = rl.student_user_id + left join public.platform_users assignee on assignee.id = rl.assigned_to_user_id left join public.orders o on o.tenant_id = rl.tenant_id and o.user_id = rl.student_user_id where rl.tenant_id = $1 and rl.referrer_user_id = $2 and rl.status = 'protected' - group by rl.id, u.id + group by rl.id, u.id, assignee.id order by rl.bound_at desc limit $3 `, @@ -823,6 +1044,10 @@ export async function crmConfigRoute(ctx: RequestContext) { ` select id, enabled, url, secret_ref as "secretRef", form_name as "formName", exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec", + assignment_mode as "assignmentMode", + assignment_pool as "assignmentPool", + assignment_cursor as "assignmentCursor", + assignment_config as "assignmentConfig", created_at as "createdAt", updated_at as "updatedAt" from public.crm_config where tenant_id = $1 @@ -838,8 +1063,12 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) { requireTenantPermission(auth, 'crm:write'); const body = await readJsonBody(ctx); const secretRef = optionalString(body, 'secretRef') || (body.secret ? 'app_private.tenant_secrets:crm:webhook' : null); + const assignmentMode = optionalChoice(body.assignmentMode, CRM_ASSIGNMENT_MODES, 'none'); + const assignmentPool = stringArray(body.assignmentPool); + const assignmentConfig = objectValue(body.assignmentConfig); const item = await transaction(async client => { + const validAssignmentPool = await validateCrmAssignmentPool(client, auth.tenantId, assignmentPool, assignmentMode); if (body.secret && typeof body.secret === 'string') { await client.query( ` @@ -858,9 +1087,10 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) { const result = await client.query( ` insert into public.crm_config ( - tenant_id, enabled, url, secret_ref, form_name, exam_type, timeout_sec, delay_sec + tenant_id, enabled, url, secret_ref, form_name, exam_type, timeout_sec, delay_sec, + assignment_mode, assignment_pool, assignment_config ) - values ($1, $2, $3, $4, $5, $6, $7, $8) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb) on conflict (tenant_id) do update set enabled = excluded.enabled, url = excluded.url, @@ -869,9 +1099,14 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) { exam_type = excluded.exam_type, timeout_sec = excluded.timeout_sec, delay_sec = excluded.delay_sec, + assignment_mode = excluded.assignment_mode, + assignment_pool = excluded.assignment_pool, + assignment_config = excluded.assignment_config, updated_at = now() returning id, enabled, url, secret_ref as "secretRef", form_name as "formName", exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec", + assignment_mode as "assignmentMode", assignment_pool as "assignmentPool", + assignment_cursor as "assignmentCursor", assignment_config as "assignmentConfig", updated_at as "updatedAt" `, [ @@ -883,6 +1118,21 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) { optionalString(body, 'examType') || '成人本科', Number(body.timeoutSec || 10), Number(body.delaySec || 60), + assignmentMode, + JSON.stringify(validAssignmentPool), + JSON.stringify(assignmentConfig), + ], + ); + await client.query( + ` + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, $2, 'crm.config.updated', 'crm_config', $3, $4::jsonb) + `, + [ + auth.tenantId, + auth.userId, + result.rows[0].id, + JSON.stringify({ enabled: body.enabled === true, assignmentMode, assignmentPoolSize: validAssignmentPool.length }), ], ); return result.rows[0]; diff --git a/apps/taro/src/pages/tenant-admin/marketing/index.tsx b/apps/taro/src/pages/tenant-admin/marketing/index.tsx index 584e10d0..49c841ea 100644 --- a/apps/taro/src/pages/tenant-admin/marketing/index.tsx +++ b/apps/taro/src/pages/tenant-admin/marketing/index.tsx @@ -62,7 +62,17 @@ export default function TenantMarketingPage() { const [commissionOrders, setCommissionOrders] = useState([]); const [settlements, setSettlements] = useState([]); const [members, setMembers] = useState([]); - const [crmForm, setCrmForm] = useState({ enabled: false, url: '', secretRef: '', formName: '刷题题库', examType: '专升本', timeoutSec: '10', delaySec: '60' }); + const [crmForm, setCrmForm] = useState({ + enabled: false, + url: '', + secretRef: '', + formName: '刷题题库', + examType: '专升本', + timeoutSec: '10', + delaySec: '60', + assignmentMode: 'none' as 'none' | 'direct' | 'round_robin' | 'referrer', + assignmentPoolText: '', + }); const [commissionForm, setCommissionForm] = useState({ defaultRatePercent: '20', minSettlementYuan: '0', settlementCycle: 'monthly' }); const [period, setPeriod] = useState({ startDate: monthStart(), endDate: today(), referrerUserId: '' }); const [memberRate, setMemberRate] = useState({ userId: '', ratePercent: '' }); @@ -112,6 +122,8 @@ export default function TenantMarketingPage() { examType: nextCrm.examType || '专升本', timeoutSec: String(nextCrm.timeoutSec || 10), delaySec: String(nextCrm.delaySec || 60), + assignmentMode: nextCrm.assignmentMode || 'none', + assignmentPoolText: (nextCrm.assignmentPool || []).join(','), }); } if (nextSettings) { @@ -142,6 +154,8 @@ export default function TenantMarketingPage() { examType: crmForm.examType.trim() || '专升本', timeoutSec: Math.max(1, Math.trunc(Number(crmForm.timeoutSec || 10))), delaySec: Math.max(0, Math.trunc(Number(crmForm.delaySec || 60))), + assignmentMode: crmForm.assignmentMode, + assignmentPool: crmForm.assignmentPoolText.split(',').map(item => item.trim()).filter(Boolean), }); setCrmConfig(result.item || null); Taro.showToast({ title: 'CRM 已保存', icon: 'success' }); @@ -304,6 +318,18 @@ export default function TenantMarketingPage() { setCrmForm(prev => ({ ...prev, examType: String(event.detail.value || '') }))} /> setCrmForm(prev => ({ ...prev, timeoutSec: String(event.detail.value || '') }))} /> setCrmForm(prev => ({ ...prev, delaySec: String(event.detail.value || '') }))} /> + + {(['none', 'referrer', 'direct', 'round_robin'] as const).map(mode => ( + + ))} + + setCrmForm(prev => ({ ...prev, assignmentPoolText: String(event.detail.value || '') }))} /> @@ -312,6 +338,15 @@ export default function TenantMarketingPage() { {crmConfig?.enabled ? 'CRM 已启用' : 'CRM 未启用'} {crmConfig?.url || '未配置 URL'} · {crmConfig?.secretRef || '未配置 secretRef'} + 分配策略 {crmConfig?.assignmentMode || 'none'} · 候选 {crmConfig?.assignmentPool?.length || 0} 人 · 游标 {crmConfig?.assignmentCursor || 0} + + + {salesMembers.slice(0, 8).map(item => ( + + {item.name || item.username || item.userId} + {item.role} · {item.userId} + + ))} diff --git a/apps/taro/src/services/tenantAdmin.ts b/apps/taro/src/services/tenantAdmin.ts index 383a9797..376eab0b 100644 --- a/apps/taro/src/services/tenantAdmin.ts +++ b/apps/taro/src/services/tenantAdmin.ts @@ -421,6 +421,10 @@ export interface CrmConfigItem { examType?: string | null; timeoutSec?: number; delaySec?: number; + assignmentMode?: 'none' | 'direct' | 'round_robin' | 'referrer'; + assignmentPool?: string[]; + assignmentCursor?: number; + assignmentConfig?: Record; updatedAt?: string; } @@ -1017,6 +1021,9 @@ export async function upsertCrmConfig(input: { examType?: string | null; timeoutSec?: number; delaySec?: number; + assignmentMode?: 'none' | 'direct' | 'round_robin' | 'referrer'; + assignmentPool?: string[]; + assignmentConfig?: Record; }) { return apiRequest<{ item?: CrmConfigItem }>('/api/crm/config', { method: 'PUT', diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index fa04d034..1d63e942 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -153,9 +153,9 @@ | 首绑客资保护 | 可联调 | `/api/referral/bind` | | 手工补绑 | 可联调 | 需要 `referral:write` | | 销售统计/客户列表/团队 | 可联调 | `/api/referral/sales-*`、`team` | -| CRM 配置/队列 | 可联调 | `/api/crm/config`、`/api/crm/queue` | +| 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 增强 | 待补齐 | 轮询/定向分配策略、富卡片模板、失败告警、死信运营后台和批量 CRM 推送 | +| CRM 增强 | 待补齐 | 富卡片模板、失败告警、死信运营后台、批量 CRM 推送和更细销售转化看板 | | 分佣结算基础闭环 | 可联调 | `/api/commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、销售/代理本人范围和租户隔离 | | 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款、结算导出、发票/凭证、财务复核和分佣看板 | diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 328ab546..ff8e9acf 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -65,7 +65,7 @@ | 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 | | 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 | | 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 | -| CRM 配置和队列 | 部分覆盖 | 配置/队列已有;钉钉/飞书/企微真实发送 worker、签名、重试、死信待补 | +| CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、钉钉/飞书/企微 worker、签名和重试已有;富卡片模板、失败告警、死信运营台和批量推送待补 | | 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有;租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 | ## 平台 SaaS 后台功能 @@ -101,7 +101,7 @@ 4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。 5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。 6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。 -7. CRM/销售结算:CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺轮询/定向分配、打款导出、凭证和销售结算看板。 +7. CRM/销售结算:CRM worker、跟进分配策略、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、打款导出、凭证和销售结算看板。 8. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 9. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。 10. 勋章增强:后台维护和手动发放已有;仍缺按学习行为、签到、积分、活动任务自动发放,以及发放通知。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 8c280daa..12f3c4c3 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -12,7 +12,7 @@ - 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。 - 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。 -- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核和打款状态;Taro 租户营销中心已接第一版操作台。 +- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核和打款状态;Taro 租户营销中心已接第一版操作台。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 - 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。 @@ -160,7 +160,8 @@ 3. CRM worker - 已完成 `apps/worker` CRM 队列消费、generic webhook、钉钉、飞书、企业微信机器人 adapter、签名、失败重试和日志。 - - 继续补轮询/定向分配、富卡片模板、失败告警、死信运营后台和批量 CRM 推送。 + - 已完成 CRM 配置中的跟进分配策略:`none/direct/round_robin/referrer`,后端校验候选人必须是当前租户内 active 的销售/代理/运营/教师/管理员,首绑客资后写入 `assignedToUserId` 并把 assignee 放进 CRM 队列 payload。 + - 继续补富卡片模板、失败告警、死信运营后台和批量 CRM 推送。 4. 运维 - 后台操作审计报表。 @@ -224,7 +225,7 @@ ## 推荐下一步顺序 -1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列、分佣规则/成员比例/结算生成审核打款已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款/导出/凭证。 +1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款/导出/凭证。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补长题干排版、公式图片混排、小程序支付容器、分享场景和状态管理。 3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。 4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index af4374ed..de0802fe 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -2122,7 +2122,18 @@ npm run build:taro:h5:platform - 勋章:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants` - 考试日期:`GET/PUT /api/tenant-admin/exam-dates` - 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` -- 销售/代理/CRM 队列、CRM 配置、分佣规则、成员分佣比例、分佣订单、结算单审核和线下打款登记;当前 Taro 租户营销中心已接第一版,真实打款 provider、结算导出、凭证和财务复核后续增强。 +- 销售/代理/CRM 队列、CRM 配置、CRM 跟进分配策略、分佣规则、成员分佣比例、分佣订单、结算单审核和线下打款登记;当前 Taro 租户营销中心已接第一版,真实打款 provider、结算导出、凭证和财务复核后续增强。 + +CRM 分配策略由后端执行,前端只提交配置: + +```json +{ + "assignmentMode": "round_robin", + "assignmentPool": ["", ""] +} +``` + +`assignmentMode` 支持 `none`、`direct`、`round_robin`、`referrer`。后端会校验 `assignmentPool` 中的用户必须是当前租户内 active 的销售、代理、运营、教师或管理员;跨租户成员会返回 `CRM_ASSIGNMENT_POOL_INVALID`。首绑客资成功后,`lead.item.assignedToUserId` 是 CRM 跟进负责人,`lead.item.referrerUserId` 仍是受首绑保护的推广/分佣归属,两者不要在前端混用。CRM 队列 `payload.assignee` 可用于展示推送目标,但前端不能自行改写客资归属或分配游标。 租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 permission keys 执行真正的访问控制。班级/学生范围权限由后端根据角色、模板 `dataScope.classIds` 和 `tenant_class_members` 计算,教师默认只能看到自己负责班级。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index cab3eaf2..25b3de01 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -6789,11 +6789,28 @@ async function testReferralAndCrmGrowth() { formName: '集成测试客资', examType: '专升本', delaySec: 1, + assignmentMode: 'round_robin', + assignmentPool: [TENANT_SALES_USER_ID, TENANT_AGENT_USER_ID], }, }); assert.equal(crmConfig.item?.enabled, true, 'tenant admin should enable CRM config'); + assert.equal(crmConfig.item?.assignmentMode, 'round_robin', 'CRM config should persist lead assignment mode'); + 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'); + const invalidCrmAssignment = await request('/api/crm/config', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + enabled: true, + url: 'https://crm.example.test/webhook', + assignmentMode: 'direct', + assignmentPool: [PARTNER_TENANT_ADMIN_USER_ID], + }, + expectStatus: 400, + }); + assert.equal(invalidCrmAssignment.code, 'CRM_ASSIGNMENT_POOL_INVALID', 'CRM assignment pool must reject cross-tenant users'); + const tracked = await request('/api/referral/track-event', { userId: false, method: 'POST', @@ -6808,6 +6825,8 @@ async function testReferralAndCrmGrowth() { assert.equal(tracked.item?.refUserId, TENANT_SALES_USER_ID, 'tracking event should resolve referrer'); assert.equal(tracked.lead?.bound, true, 'first referral event should bind lead'); assert.equal(tracked.lead?.item?.referrerUserId, TENANT_SALES_USER_ID, 'lead should be bound to sales user'); + assert.equal(tracked.lead?.item?.assignedToUserId, TENANT_SALES_USER_ID, 'round-robin should assign first lead to first sales member'); + assert.equal(tracked.lead?.assignment?.userId, TENANT_SALES_USER_ID, 'track response should expose assignment assignee'); assert.ok(tracked.crmQueue?.id, 'first lead binding should enqueue CRM task'); const secondBind = await request('/api/referral/bind', { @@ -6853,6 +6872,7 @@ async function testReferralAndCrmGrowth() { }); assert.equal(manual.lead?.bound, true, 'tenant admin should be able to force manual bind'); assert.equal(manual.lead?.item?.referrerUserId, TENANT_AGENT_USER_ID, 'manual bind should move lead to agent'); + assert.equal(manual.lead?.item?.assignedToUserId, TENANT_AGENT_USER_ID, 'round-robin should assign second lead to next pool member'); const qrcode = await request('/api/referral/qrcode', { userId: TENANT_AGENT_USER_ID, @@ -7082,6 +7102,7 @@ async function testReferralAndCrmGrowth() { query: { status: 'pending' }, }); 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(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret'); const partnerReferralDenied = await request('/api/referral/sales-stats', { diff --git a/supabase/migrations/202606290026_crm_lead_assignment.sql b/supabase/migrations/202606290026_crm_lead_assignment.sql new file mode 100644 index 00000000..874d114e --- /dev/null +++ b/supabase/migrations/202606290026_crm_lead_assignment.sql @@ -0,0 +1,30 @@ +alter table public.crm_config + add column if not exists assignment_mode text not null default 'none', + add column if not exists assignment_pool jsonb not null default '[]'::jsonb, + add column if not exists assignment_cursor integer not null default 0, + add column if not exists assignment_config jsonb not null default '{}'::jsonb; + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'crm_config_assignment_mode_check' + ) then + alter table public.crm_config + add constraint crm_config_assignment_mode_check + check (assignment_mode in ('none', 'direct', 'round_robin', 'referrer')); + end if; +end $$; + +alter table public.referral_leads + add column if not exists assigned_to_user_id uuid references public.platform_users(id) on delete set null, + add column if not exists assignment_mode text, + add column if not exists assigned_at timestamptz, + add column if not exists assigned_by uuid references public.platform_users(id) on delete set null; + +create index if not exists idx_referral_leads_assignee + on public.referral_leads(tenant_id, assigned_to_user_id, status, bound_at desc); + +create index if not exists idx_crm_config_assignment_mode + on public.crm_config(tenant_id, assignment_mode);