feat: add CRM lead assignment strategies

This commit is contained in:
Codex
2026-06-29 20:51:24 +08:00
parent f6710ace8b
commit ec23cd3b9b
10 changed files with 375 additions and 20 deletions

View File

@@ -18,7 +18,7 @@
- 平台后台能力租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 - 平台后台能力租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 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 配置、跟进分配策略和队列。
- `apps/worker` 后台任务进程CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志commerce worker 可补偿查询微信/支付宝支付和退款状态assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源imports worker 可执行大批量导入public-banks worker 可自动同步公共题库采纳副本exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。 - `apps/worker` 后台任务进程CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志commerce worker 可补偿查询微信/支付宝支付和退款状态assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源imports worker 可执行大批量导入public-banks worker 可自动同步公共题库采纳副本exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。 - 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态和权限隔离。
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。 - 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
@@ -33,7 +33,7 @@
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF/图片预览、资源访问事件、动态水印上下文、锁定资源 CDN 边界、资源复检 worker、内置 `metadata_rules` 安全扫描和外部 HTTP 杀毒/内容安全 scanner 接入层已完成。生产还要配置真实扫描服务 endpoint/token并继续补转码/CDN 级水印、CDN 刷新和对象生命周期策略。 - 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 待接。 - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 轮询/定向分配、富卡片模板、失败告警、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 - 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款、结算导出、发票/凭证、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 - `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。

View File

@@ -11,6 +11,8 @@ type JsonBody = Record<string, unknown>;
const REFERRAL_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const REFERRAL_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const EVENT_TYPES = ['enter', 'register', 'purchase', 'share', 'scan', 'manual_bind']; const EVENT_TYPES = ['enter', 'register', 'purchase', 'share', 'scan', 'manual_bind'];
const TRACK_SOURCES = ['share', 'qrcode', 'timeline', 'miniapp', 'h5', 'manual', 'unknown']; 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) { function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -28,6 +30,19 @@ function optionalChoice(value: unknown, allowed: string[], fallback: string) {
return candidate; return candidate;
} }
function stringArray(value: unknown) {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
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) { function normalizeCode(code: string) {
return code.replace(/\s+/g, '').toUpperCase(); return code.replace(/\s+/g, '').toUpperCase();
} }
@@ -65,6 +80,169 @@ async function userHasTenantMembership(client: pg.PoolClient, tenantId: string,
return Boolean(result.rows[0]); 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) { async function resolveReferralCode(tenantId: string, code: string) {
return queryOne<{ return queryOne<{
code: string; code: string;
@@ -181,12 +359,24 @@ async function enqueueCrmLead(
const lead = await client.query<{ const lead = await client.query<{
ref_code: string | null; ref_code: string | null;
referrer_user_id: string | null; referrer_user_id: string | null;
assigned_to_user_id: string | null;
assignment_mode: string | null;
bound_at: string; bound_at: string;
assignee_name: string | null;
assignee_username: string | null;
assignee_role: string | null;
}>( }>(
` `
select ref_code::text, referrer_user_id, bound_at select rl.ref_code::text, rl.referrer_user_id, rl.assigned_to_user_id,
from public.referral_leads rl.assignment_mode, rl.bound_at,
where tenant_id = $1 and id = $2 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 limit 1
`, `,
[input.tenantId, input.leadId], [input.tenantId, input.leadId],
@@ -209,6 +399,12 @@ async function enqueueCrmLead(
referrerUserId: lead.rows[0]?.referrer_user_id || null, referrerUserId: lead.rows[0]?.referrer_user_id || null,
boundAt: lead.rows[0]?.bound_at || 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 || {}, metadata: input.metadata || {},
}; };
@@ -270,12 +466,16 @@ async function bindReferralLead(
id: string; id: string;
referrerUserId: string | null; referrerUserId: string | null;
refCode: string | null; refCode: string | null;
assignedToUserId: string | null;
assignmentMode: string | null;
assignedAt: string | null;
status: string; status: string;
boundAt: string; boundAt: string;
}>( }>(
` `
select id, referrer_user_id as "referrerUserId", ref_code::text as "refCode", 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 from public.referral_leads
where tenant_id = $1 and student_user_id = $2 where tenant_id = $1 and student_user_id = $2
limit 1 limit 1
@@ -305,6 +505,8 @@ async function bindReferralLead(
updated_at = now() updated_at = now()
returning id, student_user_id as "studentUserId", referrer_user_id as "referrerUserId", returning id, student_user_id as "studentUserId", referrer_user_id as "referrerUserId",
ref_code::text as "refCode", source, bind_type as "bindType", status, 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" 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) { function crmPermission(auth: TenantAdminAuth) {
@@ -561,6 +771,7 @@ export async function referralSalesStatsRoute(ctx: RequestContext) {
lead_stats as ( lead_stats as (
select referrer_user_id, select referrer_user_id,
count(*)::int as lead_count, count(*)::int as lead_count,
count(*) filter (where assigned_to_user_id is not null)::int as assigned_lead_count,
count(*) filter (where exists ( count(*) filter (where exists (
select 1 from public.orders o select 1 from public.orders o
where o.tenant_id = rl.tenant_id 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, select r.user_id as "referrerUserId", r.role, r.username, r.name, r.phone,
r.code as "inviteCode", r.code as "inviteCode",
coalesce(ls.lead_count, 0) as "leadCount", coalesce(ls.lead_count, 0) as "leadCount",
coalesce(ls.assigned_lead_count, 0) as "assignedLeadCount",
coalesce(ls.paid_lead_count, 0) as "paidLeadCount", coalesce(ls.paid_lead_count, 0) as "paidLeadCount",
coalesce(os.paid_amount_cents, 0)::text as "paidAmountCents", coalesce(os.paid_amount_cents, 0)::text as "paidAmountCents",
coalesce(ts.track_count, 0) as "trackCount" coalesce(ts.track_count, 0) as "trackCount"
@@ -632,6 +844,10 @@ export async function referralSalesClientsRoute(ctx: RequestContext) {
source: string | null; source: string | null;
bindType: string; bindType: string;
status: string; status: string;
assignedToUserId: string | null;
assignedToName: string | null;
assignmentMode: string | null;
assignedAt: string | null;
boundAt: string; boundAt: string;
paidAmountCents: string; paidAmountCents: string;
lastPaidAt: string | null; lastPaidAt: string | null;
@@ -640,16 +856,21 @@ export async function referralSalesClientsRoute(ctx: RequestContext) {
select rl.id, rl.student_user_id as "studentUserId", select rl.id, rl.student_user_id as "studentUserId",
u.username, u.name, u.phone, u.email::text as email, u.username, u.name, u.phone, u.email::text as email,
rl.referrer_user_id as "referrerUserId", rl.ref_code::text as "refCode", 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", 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" max(o.paid_at) filter (where o.status = 'paid') as "lastPaidAt"
from public.referral_leads rl from public.referral_leads rl
join public.platform_users u on u.id = rl.student_user_id 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 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 where rl.tenant_id = $1
and rl.referrer_user_id = $2 and rl.referrer_user_id = $2
and rl.status = 'protected' and rl.status = 'protected'
group by rl.id, u.id group by rl.id, u.id, assignee.id
order by rl.bound_at desc order by rl.bound_at desc
limit $3 limit $3
`, `,
@@ -823,6 +1044,10 @@ export async function crmConfigRoute(ctx: RequestContext) {
` `
select id, enabled, url, secret_ref as "secretRef", form_name as "formName", select id, enabled, url, secret_ref as "secretRef", form_name as "formName",
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec", 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" created_at as "createdAt", updated_at as "updatedAt"
from public.crm_config from public.crm_config
where tenant_id = $1 where tenant_id = $1
@@ -838,8 +1063,12 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) {
requireTenantPermission(auth, 'crm:write'); requireTenantPermission(auth, 'crm:write');
const body = await readJsonBody(ctx); const body = await readJsonBody(ctx);
const secretRef = optionalString(body, 'secretRef') || (body.secret ? 'app_private.tenant_secrets:crm:webhook' : null); 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 item = await transaction(async client => {
const validAssignmentPool = await validateCrmAssignmentPool(client, auth.tenantId, assignmentPool, assignmentMode);
if (body.secret && typeof body.secret === 'string') { if (body.secret && typeof body.secret === 'string') {
await client.query( await client.query(
` `
@@ -858,9 +1087,10 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) {
const result = await client.query( const result = await client.query(
` `
insert into public.crm_config ( 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) on conflict (tenant_id)
do update set enabled = excluded.enabled, do update set enabled = excluded.enabled,
url = excluded.url, url = excluded.url,
@@ -869,9 +1099,14 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) {
exam_type = excluded.exam_type, exam_type = excluded.exam_type,
timeout_sec = excluded.timeout_sec, timeout_sec = excluded.timeout_sec,
delay_sec = excluded.delay_sec, delay_sec = excluded.delay_sec,
assignment_mode = excluded.assignment_mode,
assignment_pool = excluded.assignment_pool,
assignment_config = excluded.assignment_config,
updated_at = now() updated_at = now()
returning id, enabled, url, secret_ref as "secretRef", form_name as "formName", returning id, enabled, url, secret_ref as "secretRef", form_name as "formName",
exam_type as "examType", timeout_sec as "timeoutSec", delay_sec as "delaySec", 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" updated_at as "updatedAt"
`, `,
[ [
@@ -883,6 +1118,21 @@ export async function upsertCrmConfigRoute(ctx: RequestContext) {
optionalString(body, 'examType') || '成人本科', optionalString(body, 'examType') || '成人本科',
Number(body.timeoutSec || 10), Number(body.timeoutSec || 10),
Number(body.delaySec || 60), 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]; return result.rows[0];

View File

@@ -62,7 +62,17 @@ export default function TenantMarketingPage() {
const [commissionOrders, setCommissionOrders] = useState<CommissionOrderItem[]>([]); const [commissionOrders, setCommissionOrders] = useState<CommissionOrderItem[]>([]);
const [settlements, setSettlements] = useState<CommissionSettlementItem[]>([]); const [settlements, setSettlements] = useState<CommissionSettlementItem[]>([]);
const [members, setMembers] = useState<TenantMemberItem[]>([]); const [members, setMembers] = useState<TenantMemberItem[]>([]);
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 [commissionForm, setCommissionForm] = useState({ defaultRatePercent: '20', minSettlementYuan: '0', settlementCycle: 'monthly' });
const [period, setPeriod] = useState({ startDate: monthStart(), endDate: today(), referrerUserId: '' }); const [period, setPeriod] = useState({ startDate: monthStart(), endDate: today(), referrerUserId: '' });
const [memberRate, setMemberRate] = useState({ userId: '', ratePercent: '' }); const [memberRate, setMemberRate] = useState({ userId: '', ratePercent: '' });
@@ -112,6 +122,8 @@ export default function TenantMarketingPage() {
examType: nextCrm.examType || '专升本', examType: nextCrm.examType || '专升本',
timeoutSec: String(nextCrm.timeoutSec || 10), timeoutSec: String(nextCrm.timeoutSec || 10),
delaySec: String(nextCrm.delaySec || 60), delaySec: String(nextCrm.delaySec || 60),
assignmentMode: nextCrm.assignmentMode || 'none',
assignmentPoolText: (nextCrm.assignmentPool || []).join(','),
}); });
} }
if (nextSettings) { if (nextSettings) {
@@ -142,6 +154,8 @@ export default function TenantMarketingPage() {
examType: crmForm.examType.trim() || '专升本', examType: crmForm.examType.trim() || '专升本',
timeoutSec: Math.max(1, Math.trunc(Number(crmForm.timeoutSec || 10))), timeoutSec: Math.max(1, Math.trunc(Number(crmForm.timeoutSec || 10))),
delaySec: Math.max(0, Math.trunc(Number(crmForm.delaySec || 60))), 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); setCrmConfig(result.item || null);
Taro.showToast({ title: 'CRM 已保存', icon: 'success' }); Taro.showToast({ title: 'CRM 已保存', icon: 'success' });
@@ -304,6 +318,18 @@ export default function TenantMarketingPage() {
<Input className='admin-input' placeholder='考试类型' value={crmForm.examType} onInput={event => setCrmForm(prev => ({ ...prev, examType: String(event.detail.value || '') }))} /> <Input className='admin-input' placeholder='考试类型' value={crmForm.examType} onInput={event => setCrmForm(prev => ({ ...prev, examType: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='超时秒数' value={crmForm.timeoutSec} onInput={event => setCrmForm(prev => ({ ...prev, timeoutSec: String(event.detail.value || '') }))} /> <Input className='admin-input' type='number' placeholder='超时秒数' value={crmForm.timeoutSec} onInput={event => setCrmForm(prev => ({ ...prev, timeoutSec: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='延迟推送秒数' value={crmForm.delaySec} onInput={event => setCrmForm(prev => ({ ...prev, delaySec: String(event.detail.value || '') }))} /> <Input className='admin-input' type='number' placeholder='延迟推送秒数' value={crmForm.delaySec} onInput={event => setCrmForm(prev => ({ ...prev, delaySec: String(event.detail.value || '') }))} />
<View className='admin-actions compact'>
{(['none', 'referrer', 'direct', 'round_robin'] as const).map(mode => (
<Button
key={mode}
className={`admin-button ${crmForm.assignmentMode === mode ? 'active' : ''}`}
onClick={() => setCrmForm(prev => ({ ...prev, assignmentMode: mode }))}
>
{mode === 'none' ? '不分配' : mode === 'referrer' ? '归属人跟进' : mode === 'direct' ? '固定负责人' : '轮询分配'}
</Button>
))}
</View>
<Input className='admin-input' placeholder='负责人用户ID多个用英文逗号分隔' value={crmForm.assignmentPoolText} onInput={event => setCrmForm(prev => ({ ...prev, assignmentPoolText: String(event.detail.value || '') }))} />
</View> </View>
<View className='admin-actions compact'> <View className='admin-actions compact'>
<Button className='admin-button primary' loading={busy === 'crm'} onClick={saveCrmConfig}> CRM</Button> <Button className='admin-button primary' loading={busy === 'crm'} onClick={saveCrmConfig}> CRM</Button>
@@ -312,6 +338,15 @@ export default function TenantMarketingPage() {
<View className='admin-row'> <View className='admin-row'>
<Text className='admin-row-main'>{crmConfig?.enabled ? 'CRM 已启用' : 'CRM 未启用'}</Text> <Text className='admin-row-main'>{crmConfig?.enabled ? 'CRM 已启用' : 'CRM 未启用'}</Text>
<Text className='admin-row-meta'>{crmConfig?.url || '未配置 URL'} · {crmConfig?.secretRef || '未配置 secretRef'}</Text> <Text className='admin-row-meta'>{crmConfig?.url || '未配置 URL'} · {crmConfig?.secretRef || '未配置 secretRef'}</Text>
<Text className='admin-row-meta'> {crmConfig?.assignmentMode || 'none'} · {crmConfig?.assignmentPool?.length || 0} · {crmConfig?.assignmentCursor || 0}</Text>
</View>
<View className='admin-list compact'>
{salesMembers.slice(0, 8).map(item => (
<View className='admin-row' key={item.userId}>
<Text className='admin-row-main'>{item.name || item.username || item.userId}</Text>
<Text className='admin-row-meta'>{item.role} · {item.userId}</Text>
</View>
))}
</View> </View>
</View> </View>

View File

@@ -421,6 +421,10 @@ export interface CrmConfigItem {
examType?: string | null; examType?: string | null;
timeoutSec?: number; timeoutSec?: number;
delaySec?: number; delaySec?: number;
assignmentMode?: 'none' | 'direct' | 'round_robin' | 'referrer';
assignmentPool?: string[];
assignmentCursor?: number;
assignmentConfig?: Record<string, unknown>;
updatedAt?: string; updatedAt?: string;
} }
@@ -1017,6 +1021,9 @@ export async function upsertCrmConfig(input: {
examType?: string | null; examType?: string | null;
timeoutSec?: number; timeoutSec?: number;
delaySec?: number; delaySec?: number;
assignmentMode?: 'none' | 'direct' | 'round_robin' | 'referrer';
assignmentPool?: string[];
assignmentConfig?: Record<string, unknown>;
}) { }) {
return apiRequest<{ item?: CrmConfigItem }>('/api/crm/config', { return apiRequest<{ item?: CrmConfigItem }>('/api/crm/config', {
method: 'PUT', method: 'PUT',

View File

@@ -153,9 +153,9 @@
| 首绑客资保护 | 可联调 | `/api/referral/bind` | | 首绑客资保护 | 可联调 | `/api/referral/bind` |
| 手工补绑 | 可联调 | 需要 `referral:write` | | 手工补绑 | 可联调 | 需要 `referral:write` |
| 销售统计/客户列表/团队 | 可联调 | `/api/referral/sales-*``team` | | 销售统计/客户列表/团队 | 可联调 | `/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 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`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、销售/代理本人范围和租户隔离 | | 分佣结算基础闭环 | 可联调 | `/api/commission/settings``member-rate``summary``orders``settlements``settlements/generate``settlements/status`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、销售/代理本人范围和租户隔离 |
| 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款、结算导出、发票/凭证、财务复核和分佣看板 | | 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款、结算导出、发票/凭证、财务复核和分佣看板 |

View File

@@ -65,7 +65,7 @@
| 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 | | 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 |
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 | | 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 | | 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
| CRM 配置和队列 | 部分覆盖 | 配置/队列已有;钉钉/飞书/企微真实发送 worker、签名重试、死信待补 | | CRM 配置和队列 | 部分覆盖 | 配置/队列`none/direct/round_robin/referrer` 跟进分配策略、钉钉/飞书/企微 worker、签名重试已有;富卡片模板、失败告警、死信运营台和批量推送待补 |
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 | | 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 |
## 平台 SaaS 后台功能 ## 平台 SaaS 后台功能
@@ -101,7 +101,7 @@
4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。 4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。
5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。 5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。 6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
7. CRM/销售结算CRM worker、分佣规则、结算单、审核和打款状态基础闭环已完成仍缺轮询/定向分配、打款导出、凭证和销售结算看板。 7. CRM/销售结算CRM worker、跟进分配策略、分佣规则、结算单、审核和打款状态基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、打款导出、凭证和销售结算看板。
8. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 8. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
9. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。 9. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。
10. 勋章增强:后台维护和手动发放已有;仍缺按学习行为、签到、积分、活动任务自动发放,以及发放通知。 10. 勋章增强:后台维护和手动发放已有;仍缺按学习行为、签到、积分、活动任务自动发放,以及发放通知。

View File

@@ -12,7 +12,7 @@
- 租户后台 API品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 租户后台 API品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
- 租户主题系统平台默认经典蓝、专注绿、高对比三套模板租户可保存草稿、发布主题公开租户解析只返回已发布主题Taro 租户设置页已接第一版主题操作台。 - 租户主题系统平台默认经典蓝、专注绿、高对比三套模板租户可保存草稿、发布主题公开租户解析只返回已发布主题Taro 租户设置页已接第一版主题操作台。
- 平台后台 API租户、SaaS 套餐、订阅、账单、服务费收款、用量。 - 平台后台 API租户、SaaS 套餐、订阅、账单、服务费收款、用量。
- 销售/代理/CRM 增长链路邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核和打款状态Taro 租户营销中心已接第一版操作台。 - 销售/代理/CRM 增长链路邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核和打款状态Taro 租户营销中心已接第一版操作台。
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
- 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats``trend``practice-sessions/history``wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。 - 模考报告与学习统计:`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 3. CRM worker
- 已完成 `apps/worker` CRM 队列消费、generic webhook、钉钉、飞书、企业微信机器人 adapter、签名、失败重试和日志。 - 已完成 `apps/worker` CRM 队列消费、generic webhook、钉钉、飞书、企业微信机器人 adapter、签名、失败重试和日志。
- 继续补轮询/定向分配、富卡片模板、失败告警、死信运营后台和批量 CRM 推送 - 已完成 CRM 配置中的跟进分配策略:`none/direct/round_robin/referrer`,后端校验候选人必须是当前租户内 active 的销售/代理/运营/教师/管理员,首绑客资后写入 `assignedToUserId` 并把 assignee 放进 CRM 队列 payload
- 继续补富卡片模板、失败告警、死信运营后台和批量 CRM 推送。
4. 运维 4. 运维
- 后台操作审计报表。 - 后台操作审计报表。
@@ -224,7 +225,7 @@
## 推荐下一步顺序 ## 推荐下一步顺序
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列、分佣规则/成员比例/结算生成审核打款已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款/导出/凭证。 1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款/导出/凭证。
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补长题干排版、公式图片混排、小程序支付容器、分享场景和状态管理。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、错题/收藏专题、收银台、订单详情和售后入口已接第一版;继续补长题干排版、公式图片混排、小程序支付容器、分享场景和状态管理。
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。 3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。 4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。

View File

@@ -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/badges``GET/POST /api/tenant-admin/badge-grants`
- 考试日期:`GET/PUT /api/tenant-admin/exam-dates` - 考试日期:`GET/PUT /api/tenant-admin/exam-dates`
- 题目反馈:`GET /api/tenant-admin/feedbacks``POST /api/tenant-admin/feedbacks/status``GET /api/tenant-admin/feedbacks/events` - 题目反馈:`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": ["<salesUserId>", "<agentUserId>"]
}
```
`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` 计算,教师默认只能看到自己负责班级。 租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 permission keys 执行真正的访问控制。班级/学生范围权限由后端根据角色、模板 `dataScope.classIds``tenant_class_members` 计算,教师默认只能看到自己负责班级。

View File

@@ -6789,11 +6789,28 @@ async function testReferralAndCrmGrowth() {
formName: '集成测试客资', formName: '集成测试客资',
examType: '专升本', examType: '专升本',
delaySec: 1, 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?.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'); 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', { const tracked = await request('/api/referral/track-event', {
userId: false, userId: false,
method: 'POST', 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.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?.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?.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'); assert.ok(tracked.crmQueue?.id, 'first lead binding should enqueue CRM task');
const secondBind = await request('/api/referral/bind', { 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?.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?.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', { const qrcode = await request('/api/referral/qrcode', {
userId: TENANT_AGENT_USER_ID, userId: TENANT_AGENT_USER_ID,
@@ -7082,6 +7102,7 @@ async function testReferralAndCrmGrowth() {
query: { status: 'pending' }, 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.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'); 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,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);