forked from wangziqi/gongxue-base
feat: add student crm push
This commit is contained in:
@@ -12,14 +12,14 @@
|
|||||||
|
|
||||||
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
|
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
|
||||||
- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
|
- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
|
||||||
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
|
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、学生批量 CRM 推送、审计日志。
|
||||||
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
||||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口(租户默认关闭)、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。
|
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口(租户默认关闭)、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。
|
||||||
- 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。
|
- 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。
|
||||||
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细;worker 失败会生成租户 `public_question_bank_sync_failed` 通知,恢复成功自动关闭失败通知,平台可用 `/api/platform-admin/question-bank-sync-status` 按 `platform:question_bank:ops` 查看跨租户同步运营摘要。
|
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细;worker 失败会生成租户 `public_question_bank_sync_failed` 通知,恢复成功自动关闭失败通知,平台可用 `/api/platform-admin/question-bank-sync-status` 按 `platform:question_bank:ops` 查看跨租户同步运营摘要。
|
||||||
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
|
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。
|
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略、客资队列和学生批量 CRM 跟进推送。
|
||||||
- `apps/worker` 后台任务进程:CRM webhook 队列消费、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;provider-bills worker 可下载微信/支付宝官方账单并导入资金对账;platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计;platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计;platform-dunning-notifications worker 可把内部催缴记录按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;platform-audit-alerts worker 可把高风险平台审计动作转换为内部告警并递归脱敏告警 details;platform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
|
- `apps/worker` 后台任务进程:CRM webhook 队列消费、`lead.created` 客资事件、`student.crm_push` 学生跟进事件、generic/钉钉/飞书/企微机器人发送、签名、失败重试和日志;commerce worker 可补偿查询微信/支付宝支付和退款状态;provider-bills worker 可下载微信/支付宝官方账单并导入资金对账;platform-billing worker 可自动为即将到期且未开票的 SaaS 订阅生成服务费账单并写审计;platform-dunning worker 可扫描逾期未结清服务费账单、标记 overdue、写内部催缴记录和审计;platform-dunning-notifications worker 可把内部催缴记录按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;platform-audit-alerts worker 可把高风险平台审计动作转换为内部告警并递归脱敏告警 details;platform-audit-notifications worker 可把开放审计告警按平台渠道推送到 generic/钉钉/飞书/企微 webhook,并记录幂等发送事件;assets worker 可复检托管资源元数据、执行内置安全扫描并自动下架异常资源;imports worker 可执行大批量导入;public-banks worker 可自动同步公共题库采纳副本;exports worker 可渲染 PDF/Word 导出文件和每日一练 ZIP 图片素材包。
|
||||||
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。
|
- 销售/代理分佣结算基础闭环:租户默认比例、成员比例、激活码批次比例、订单/激活码归因、结算单生成、审核、线下打款状态、CSV/JSON 导出、打款凭证登记/复核和权限隔离。
|
||||||
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
- 订单售后基础闭环:退款请求、审核、处理状态流、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、退款金额累计、部分/全额退款订单状态、全额退款权益撤销、退款事件和审计日志。
|
||||||
- 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。
|
- 资金对账、异常订单和财务凭证闭环:租户财务/运营可通过 `/api/commerce/reconciliation/*` 导入或预览支付/退款账单行,也可创建微信/支付宝官方账单下载任务;后端按租户隔离比对本地订单、支付、退款记录,识别已匹配、金额不一致、状态不一致、供应商有本地无、本地有供应商无、重复行和无效行,并写入对账批次、明细和审计日志;异常明细可创建差错工单,支持分配、开始处理、升级、解决、忽略、重开和事件留痕;`/api/commerce/operations/anomalies` 聚合异常订单风险,`/api/commerce/adjustment-vouchers*` 支持人工调整凭证、复核、事件轨迹和报表。工单和凭证只做财务审核闭环,不直接修改订单、支付、退款或权益。
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import type pg from 'pg';
|
import type pg from 'pg';
|
||||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||||
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||||
@@ -22,13 +23,33 @@ const STUDENT_FOLLOWUP_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
|||||||
const STUDENT_FOLLOWUP_STATUSES = ['open', 'in_progress', 'done', 'cancelled'];
|
const STUDENT_FOLLOWUP_STATUSES = ['open', 'in_progress', 'done', 'cancelled'];
|
||||||
const MAX_BULK_STUDENTS = 200;
|
const MAX_BULK_STUDENTS = 200;
|
||||||
const MAX_BULK_CLASS_ASSIGNMENTS = 500;
|
const MAX_BULK_CLASS_ASSIGNMENTS = 500;
|
||||||
|
const MAX_BULK_CRM_PUSH = 100;
|
||||||
|
const CRM_ASSIGNABLE_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'];
|
||||||
const STUDENT_AVATAR_FIELD_KEYS = ['avatarUrl', 'avatar_url', 'avatar', 'headimgurl', 'headImgUrl', 'figureurl', 'figureurl_qq_1', 'figureurl_qq_2'];
|
const STUDENT_AVATAR_FIELD_KEYS = ['avatarUrl', 'avatar_url', 'avatar', 'headimgurl', 'headImgUrl', 'figureurl', 'figureurl_qq_1', 'figureurl_qq_2'];
|
||||||
const STUDENT_PRIMARY_ROLE_FIELD_KEYS = ['primaryRole', 'primary_role'];
|
const STUDENT_PRIMARY_ROLE_FIELD_KEYS = ['primaryRole', 'primary_role'];
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
function nullableString(value: unknown) {
|
function nullableString(value: unknown) {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function limitedNullableString(value: unknown, maxLength: number, fieldName: string) {
|
||||||
|
const candidate = nullableString(value);
|
||||||
|
if (!candidate) return null;
|
||||||
|
if (candidate.length > maxLength) {
|
||||||
|
throw new HttpError(400, `${fieldName} is too long`, 'FIELD_TOO_LONG');
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredLimitedString(body: JsonBody, key: string, maxLength: number) {
|
||||||
|
const value = requiredString(body, key);
|
||||||
|
if (value.length > maxLength) {
|
||||||
|
throw new HttpError(400, `${key} is too long`, 'FIELD_TOO_LONG');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function objectValue(value: unknown): Record<string, unknown> {
|
function objectValue(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
}
|
}
|
||||||
@@ -80,7 +101,42 @@ function uuidArrayValue(value: unknown) {
|
|||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value
|
return value
|
||||||
.map(item => (typeof item === 'string' ? item.trim() : ''))
|
.map(item => (typeof item === 'string' ? item.trim() : ''))
|
||||||
.filter(item => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(item));
|
.filter(item => UUID_PATTERN.test(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function strictUuidArrayValue(value: unknown, fieldName: string, maxLength: number) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new HttpError(400, `${fieldName} must be an array`, 'INVALID_ARRAY_FIELD');
|
||||||
|
}
|
||||||
|
if (value.length > maxLength) {
|
||||||
|
throw new HttpError(413, `${fieldName} supports at most ${maxLength} items`, 'BULK_LIMIT_EXCEEDED');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
const item = typeof value[index] === 'string' ? value[index].trim() : '';
|
||||||
|
if (!UUID_PATTERN.test(item)) {
|
||||||
|
throw new HttpError(400, `${fieldName}[${index}] must be a uuid`, 'INVALID_UUID_FIELD');
|
||||||
|
}
|
||||||
|
const normalized = item.toLowerCase();
|
||||||
|
if (!seen.has(normalized)) {
|
||||||
|
seen.add(normalized);
|
||||||
|
items.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!items.length) throw new HttpError(400, `${fieldName} is required`, 'REQUIRED_FIELD');
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalTimestampString(value: unknown, fieldName: string) {
|
||||||
|
const candidate = nullableString(value);
|
||||||
|
if (!candidate) return null;
|
||||||
|
const time = Date.parse(candidate);
|
||||||
|
if (!Number.isFinite(time)) {
|
||||||
|
throw new HttpError(400, `${fieldName} must be a valid datetime`, 'INVALID_DATETIME');
|
||||||
|
}
|
||||||
|
return new Date(time).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function classCodeValue(value: unknown) {
|
function classCodeValue(value: unknown) {
|
||||||
@@ -353,6 +409,150 @@ async function ensureTenantMembership(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StudentCrmPushConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
url: string | null;
|
||||||
|
formName: string | null;
|
||||||
|
examType: string | null;
|
||||||
|
delaySec: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCrmPushConfig(client: pg.PoolClient, tenantId: string) {
|
||||||
|
const result = await client.query<StudentCrmPushConfig>(
|
||||||
|
`
|
||||||
|
select enabled, url, form_name as "formName", exam_type as "examType", delay_sec as "delaySec"
|
||||||
|
from public.crm_config
|
||||||
|
where tenant_id = $1
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[tenantId],
|
||||||
|
);
|
||||||
|
const config = result.rows[0] || null;
|
||||||
|
if (!config?.enabled || !config.url) {
|
||||||
|
throw new HttpError(409, 'CRM config is disabled or missing webhook URL', 'CRM_CONFIG_REQUIRED');
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crmPushIdempotencyKey(inputKey: string | null, requestId: string, studentUserId: string) {
|
||||||
|
const rawKey = inputKey || requestId;
|
||||||
|
const normalized = rawKey.length <= 80 ? rawKey : createHash('sha256').update(rawKey).digest('hex');
|
||||||
|
return `student_crm_push:${normalized}:${studentUserId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStudentCrmPayload(
|
||||||
|
client: pg.PoolClient,
|
||||||
|
auth: TenantAdminAuth,
|
||||||
|
studentUserId: string,
|
||||||
|
includePhone: boolean,
|
||||||
|
) {
|
||||||
|
const result = await client.query<Record<string, unknown>>(
|
||||||
|
`
|
||||||
|
with class_agg as (
|
||||||
|
select tcm.tenant_id, tcm.user_id,
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'classId', tc.id,
|
||||||
|
'className', tc.name,
|
||||||
|
'classCode', tc.code
|
||||||
|
)
|
||||||
|
order by tc.sort_order asc, tc.created_at desc
|
||||||
|
) filter (where tcm.status = 'active') as classes
|
||||||
|
from public.tenant_class_members tcm
|
||||||
|
join public.tenant_classes tc on tc.tenant_id = tcm.tenant_id and tc.id = tcm.class_id
|
||||||
|
where tcm.tenant_id = $1
|
||||||
|
and tcm.user_id = $2::uuid
|
||||||
|
and tcm.member_type = 'student'
|
||||||
|
group by tcm.tenant_id, tcm.user_id
|
||||||
|
)
|
||||||
|
select u.id as "userId", u.username, u.name,
|
||||||
|
case when $3::boolean then u.phone else null end as phone,
|
||||||
|
u.email::text as email,
|
||||||
|
coalesce(sp.avatar_preset, 'male') as "avatarPreset",
|
||||||
|
sp.region_id as "regionId", r.name as "regionName",
|
||||||
|
sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName",
|
||||||
|
sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName",
|
||||||
|
coalesce(ca.classes, '[]'::jsonb) as classes
|
||||||
|
from public.tenant_memberships tm
|
||||||
|
join public.platform_users u on u.id = tm.user_id
|
||||||
|
left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id
|
||||||
|
left join public.regions r on r.tenant_id = tm.tenant_id and r.id = sp.region_id
|
||||||
|
left join public.schools s on s.tenant_id = tm.tenant_id and s.id = sp.selected_school_id
|
||||||
|
left join public.majors m on m.tenant_id = tm.tenant_id and m.id = sp.selected_major_id
|
||||||
|
left join class_agg ca on ca.tenant_id = tm.tenant_id and ca.user_id = tm.user_id
|
||||||
|
where tm.tenant_id = $1
|
||||||
|
and tm.user_id = $2::uuid
|
||||||
|
and tm.role = 'student'
|
||||||
|
and tm.status = 'active'
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[auth.tenantId, studentUserId, includePhone],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) throw new HttpError(404, 'Student not found', 'STUDENT_NOT_FOUND');
|
||||||
|
return result.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCrmAssigneePayload(client: pg.PoolClient, tenantId: string, userId: string | null) {
|
||||||
|
if (!userId) return null;
|
||||||
|
const result = await client.query<Record<string, unknown>>(
|
||||||
|
`
|
||||||
|
select u.id as "userId", u.username, u.name, tm.role
|
||||||
|
from public.tenant_memberships tm
|
||||||
|
join public.platform_users u on u.id = tm.user_id
|
||||||
|
where tm.tenant_id = $1
|
||||||
|
and tm.user_id = $2::uuid
|
||||||
|
and tm.role = any($3::text[])
|
||||||
|
and tm.status = 'active'
|
||||||
|
order by case tm.role
|
||||||
|
when 'tenant_owner' then 1
|
||||||
|
when 'tenant_admin' then 2
|
||||||
|
when 'tenant_operator' then 3
|
||||||
|
when 'teacher' then 4
|
||||||
|
when 'sales' then 5
|
||||||
|
when 'agent' then 6
|
||||||
|
else 9
|
||||||
|
end
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[tenantId, userId, CRM_ASSIGNABLE_ROLES],
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCrmClassPayload(client: pg.PoolClient, tenantId: string, classId: string | null) {
|
||||||
|
if (!classId) return null;
|
||||||
|
const result = await client.query<Record<string, unknown>>(
|
||||||
|
`
|
||||||
|
select id as "classId", code as "classCode", name as "className"
|
||||||
|
from public.tenant_classes
|
||||||
|
where tenant_id = $1 and id = $2::uuid
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[tenantId, classId],
|
||||||
|
);
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureStudentInClass(client: pg.PoolClient, tenantId: string, studentUserId: string, classId: string | null) {
|
||||||
|
if (!classId) return;
|
||||||
|
const result = await client.query<{ id: string }>(
|
||||||
|
`
|
||||||
|
select id
|
||||||
|
from public.tenant_class_members
|
||||||
|
where tenant_id = $1
|
||||||
|
and class_id = $2::uuid
|
||||||
|
and user_id = $3::uuid
|
||||||
|
and member_type = 'student'
|
||||||
|
and status = 'active'
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[tenantId, classId, studentUserId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) {
|
||||||
|
throw new HttpError(400, 'Student is not an active member of the selected class', 'STUDENT_CLASS_REQUIRED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function membershipRoleForClassMember(memberType: string) {
|
function membershipRoleForClassMember(memberType: string) {
|
||||||
return memberType === 'student' ? 'student' : 'teacher';
|
return memberType === 'student' ? 'student' : 'teacher';
|
||||||
}
|
}
|
||||||
@@ -1267,3 +1467,209 @@ export async function upsertTenantStudentFollowupRoute(ctx: RequestContext) {
|
|||||||
|
|
||||||
return { item };
|
return { item };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pushTenantStudentsToCrmRoute(ctx: RequestContext) {
|
||||||
|
const auth = await requireTenantAdmin(ctx);
|
||||||
|
requireTenantPermission(auth, 'crm:write');
|
||||||
|
requireTenantPermission(auth, 'students:read');
|
||||||
|
requireTenantPermission(auth, 'students:followups:write');
|
||||||
|
const body = await readJsonBody(ctx);
|
||||||
|
|
||||||
|
const studentUserIds = strictUuidArrayValue(body.studentUserIds || body.userIds, 'studentUserIds', MAX_BULK_CRM_PUSH);
|
||||||
|
const classId = nullableString(body.classId);
|
||||||
|
if (classId) await ensureReadableClass(auth, classId);
|
||||||
|
|
||||||
|
const source = limitedNullableString(body.source, 80, 'source') || 'manual_batch';
|
||||||
|
const title = requiredLimitedString(body, 'title', 120);
|
||||||
|
const message = limitedNullableString(body.message ?? body.description, 1000, 'message');
|
||||||
|
const followupType = optionalChoice(body.followupType, STUDENT_FOLLOWUP_TYPES, 'sales');
|
||||||
|
const priority = optionalChoice(body.priority, STUDENT_FOLLOWUP_PRIORITIES, 'normal');
|
||||||
|
const assignedToUserId = nullableString(body.assignedToUserId);
|
||||||
|
const dueAt = optionalTimestampString(body.dueAt, 'dueAt');
|
||||||
|
const metadata = objectValue(body.metadata);
|
||||||
|
const requestId = randomUUID();
|
||||||
|
const idempotencyInput = limitedNullableString(body.idempotencyKey, 160, 'idempotencyKey');
|
||||||
|
const includePhone = canSeeStudentPhone(auth);
|
||||||
|
|
||||||
|
const result = await transaction(async client => {
|
||||||
|
const config = await loadCrmPushConfig(client, auth.tenantId);
|
||||||
|
if (classId) await ensureTenantClass(client, auth.tenantId, classId);
|
||||||
|
await ensureUserTenantMembership(client, auth.tenantId, assignedToUserId, CRM_ASSIGNABLE_ROLES, 'ASSIGNEE_NOT_FOUND');
|
||||||
|
const assignee = await loadCrmAssigneePayload(client, auth.tenantId, assignedToUserId);
|
||||||
|
const classPayload = await loadCrmClassPayload(client, auth.tenantId, classId);
|
||||||
|
const delaySeconds = Math.max(0, Number(config.delaySec ?? 60));
|
||||||
|
const items: unknown[] = [];
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < studentUserIds.length; index += 1) {
|
||||||
|
const studentUserId = studentUserIds[index];
|
||||||
|
try {
|
||||||
|
await ensureStudentInScope(auth, studentUserId);
|
||||||
|
await ensureStudentInClass(client, auth.tenantId, studentUserId, classId);
|
||||||
|
const student = await loadStudentCrmPayload(client, auth, studentUserId, includePhone);
|
||||||
|
const idempotencyKey = crmPushIdempotencyKey(idempotencyInput, requestId, studentUserId);
|
||||||
|
const followupMetadata = {
|
||||||
|
...metadata,
|
||||||
|
crmPush: {
|
||||||
|
requestId,
|
||||||
|
idempotencyKey,
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let followup = (await client.query<{
|
||||||
|
id: string;
|
||||||
|
studentUserId: string;
|
||||||
|
assignedToUserId: string | null;
|
||||||
|
classId: string | null;
|
||||||
|
title: string;
|
||||||
|
dueAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select id, student_user_id as "studentUserId",
|
||||||
|
assigned_to_user_id as "assignedToUserId", class_id as "classId",
|
||||||
|
title, due_at as "dueAt", created_at as "createdAt"
|
||||||
|
from public.tenant_student_followups
|
||||||
|
where tenant_id = $1
|
||||||
|
and student_user_id = $2::uuid
|
||||||
|
and metadata->'crmPush'->>'idempotencyKey' = $3
|
||||||
|
limit 1
|
||||||
|
`,
|
||||||
|
[auth.tenantId, studentUserId, idempotencyKey],
|
||||||
|
)).rows[0];
|
||||||
|
|
||||||
|
if (!followup) {
|
||||||
|
followup = (await client.query(
|
||||||
|
`
|
||||||
|
insert into public.tenant_student_followups (
|
||||||
|
tenant_id, student_user_id, assigned_to_user_id, class_id,
|
||||||
|
title, description, followup_type, priority, status, due_at,
|
||||||
|
metadata, created_by, updated_by
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
$1, $2::uuid, $3::uuid, $4::uuid,
|
||||||
|
$5, $6, $7, $8, 'open', $9::timestamptz,
|
||||||
|
$10::jsonb, $11, $11
|
||||||
|
)
|
||||||
|
returning id, student_user_id as "studentUserId",
|
||||||
|
assigned_to_user_id as "assignedToUserId", class_id as "classId",
|
||||||
|
title, due_at as "dueAt", created_at as "createdAt"
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
auth.tenantId,
|
||||||
|
studentUserId,
|
||||||
|
assignedToUserId,
|
||||||
|
classId,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
followupType,
|
||||||
|
priority,
|
||||||
|
dueAt,
|
||||||
|
JSON.stringify(followupMetadata),
|
||||||
|
auth.userId,
|
||||||
|
],
|
||||||
|
)).rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
eventType: 'student.crm_push',
|
||||||
|
formName: config.formName || '刷题题库',
|
||||||
|
examType: config.examType || '成人本科',
|
||||||
|
source,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
student,
|
||||||
|
assignee,
|
||||||
|
class: classPayload,
|
||||||
|
followup: {
|
||||||
|
id: followup.id,
|
||||||
|
title: followup.title,
|
||||||
|
followupType,
|
||||||
|
priority,
|
||||||
|
dueAt: followup.dueAt,
|
||||||
|
},
|
||||||
|
actor: {
|
||||||
|
userId: auth.userId,
|
||||||
|
role: auth.role,
|
||||||
|
},
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
const queued = await client.query<{
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
scheduledAt: string | null;
|
||||||
|
idempotencyKey: string;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
insert into public.crm_webhook_queue (
|
||||||
|
tenant_id, record_id, status, scheduled_at, next_attempt_at, lead_id,
|
||||||
|
source, payload, idempotency_key, target_url
|
||||||
|
)
|
||||||
|
values ($1, $2, 'pending', now() + ($3::int * interval '1 second'), null, null, $4, $5::jsonb, $6, $7)
|
||||||
|
on conflict (tenant_id, idempotency_key) where idempotency_key is not null
|
||||||
|
do update set status = case
|
||||||
|
when public.crm_webhook_queue.status = 'sent' then public.crm_webhook_queue.status
|
||||||
|
else 'pending'
|
||||||
|
end,
|
||||||
|
scheduled_at = excluded.scheduled_at,
|
||||||
|
next_attempt_at = null,
|
||||||
|
payload = excluded.payload,
|
||||||
|
target_url = excluded.target_url,
|
||||||
|
updated_at = now()
|
||||||
|
returning id, status, scheduled_at as "scheduledAt", idempotency_key as "idempotencyKey"
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
auth.tenantId,
|
||||||
|
studentUserId,
|
||||||
|
delaySeconds,
|
||||||
|
'tenant.student.crm_push',
|
||||||
|
JSON.stringify(payload),
|
||||||
|
idempotencyKey,
|
||||||
|
config.url,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
index,
|
||||||
|
studentUserId,
|
||||||
|
followupId: followup.id,
|
||||||
|
queueId: queued.rows[0].id,
|
||||||
|
queueStatus: queued.rows[0].status,
|
||||||
|
scheduledAt: queued.rows[0].scheduledAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
index,
|
||||||
|
studentUserId,
|
||||||
|
code: error instanceof HttpError ? error.code : 'STUDENT_CRM_PUSH_ITEM_FAILED',
|
||||||
|
message: error instanceof Error ? error.message : 'Student CRM push item failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordAudit(client, auth, 'tenant.students.crm_pushed', 'crm_webhook_queue', null, {
|
||||||
|
requestId,
|
||||||
|
total: studentUserIds.length,
|
||||||
|
successCount: items.length,
|
||||||
|
errorCount: errors.length,
|
||||||
|
source,
|
||||||
|
followupType,
|
||||||
|
priority,
|
||||||
|
assignedToUserId,
|
||||||
|
classId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
total: studentUserIds.length,
|
||||||
|
successCount: items.length,
|
||||||
|
errorCount: errors.length,
|
||||||
|
items,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
tenantStudentNotesRoute,
|
tenantStudentNotesRoute,
|
||||||
tenantStudentsRoute,
|
tenantStudentsRoute,
|
||||||
tenantTeachersRoute,
|
tenantTeachersRoute,
|
||||||
|
pushTenantStudentsToCrmRoute,
|
||||||
updateTenantStudentStatusRoute,
|
updateTenantStudentStatusRoute,
|
||||||
upsertTenantStudentFollowupRoute,
|
upsertTenantStudentFollowupRoute,
|
||||||
upsertTenantStudentNoteRoute,
|
upsertTenantStudentNoteRoute,
|
||||||
@@ -97,6 +98,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [
|
|||||||
['PUT', '/api/tenant-admin/students', upsertTenantStudentRoute],
|
['PUT', '/api/tenant-admin/students', upsertTenantStudentRoute],
|
||||||
['POST', '/api/tenant-admin/students/bulk-upsert', bulkUpsertTenantStudentsRoute],
|
['POST', '/api/tenant-admin/students/bulk-upsert', bulkUpsertTenantStudentsRoute],
|
||||||
['POST', '/api/tenant-admin/students/status', updateTenantStudentStatusRoute],
|
['POST', '/api/tenant-admin/students/status', updateTenantStudentStatusRoute],
|
||||||
|
['POST', '/api/tenant-admin/students/crm-push', pushTenantStudentsToCrmRoute],
|
||||||
['GET', '/api/tenant-admin/students/notes', tenantStudentNotesRoute],
|
['GET', '/api/tenant-admin/students/notes', tenantStudentNotesRoute],
|
||||||
['PUT', '/api/tenant-admin/students/notes', upsertTenantStudentNoteRoute],
|
['PUT', '/api/tenant-admin/students/notes', upsertTenantStudentNoteRoute],
|
||||||
['GET', '/api/tenant-admin/students/followups', tenantStudentFollowupsRoute],
|
['GET', '/api/tenant-admin/students/followups', tenantStudentFollowupsRoute],
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
loadTenantStudentNotes,
|
loadTenantStudentNotes,
|
||||||
loadTenantStudents,
|
loadTenantStudents,
|
||||||
loadTenantTeachers,
|
loadTenantTeachers,
|
||||||
|
loadTenantMembers,
|
||||||
|
pushTenantStudentsToCrm,
|
||||||
updateTenantStudentStatus,
|
updateTenantStudentStatus,
|
||||||
upsertTenantStudent,
|
upsertTenantStudent,
|
||||||
upsertTenantStudentFollowup,
|
upsertTenantStudentFollowup,
|
||||||
@@ -19,7 +21,9 @@ import {
|
|||||||
type TenantStudentInput,
|
type TenantStudentInput,
|
||||||
type TenantStudentItem,
|
type TenantStudentItem,
|
||||||
type TenantStudentNoteItem,
|
type TenantStudentNoteItem,
|
||||||
|
type TenantStudentCrmPushInput,
|
||||||
type TenantTeacherItem,
|
type TenantTeacherItem,
|
||||||
|
type TenantMemberItem,
|
||||||
} from '@/services/tenantAdmin';
|
} from '@/services/tenantAdmin';
|
||||||
import '../admin.css';
|
import '../admin.css';
|
||||||
|
|
||||||
@@ -28,6 +32,7 @@ const noteTypes = ['general', 'learning', 'service', 'sales', 'risk', 'follow_up
|
|||||||
const followupTypes = ['learning', 'service', 'sales', 'renewal', 'risk', 'custom'];
|
const followupTypes = ['learning', 'service', 'sales', 'renewal', 'risk', 'custom'];
|
||||||
const followupPriorities = ['low', 'normal', 'high', 'urgent'];
|
const followupPriorities = ['low', 'normal', 'high', 'urgent'];
|
||||||
const followupStatuses = ['open', 'in_progress', 'done', 'cancelled'];
|
const followupStatuses = ['open', 'in_progress', 'done', 'cancelled'];
|
||||||
|
const crmAssignableRoles = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'];
|
||||||
|
|
||||||
function emptyStudentForm(): TenantStudentInput {
|
function emptyStudentForm(): TenantStudentInput {
|
||||||
return {
|
return {
|
||||||
@@ -74,6 +79,13 @@ function resultSummary(result: BulkOperationResult | null) {
|
|||||||
return `总数 ${result.total || 0} · 成功 ${result.successCount || 0} · 失败 ${result.errorCount || 0}`;
|
return `总数 ${result.total || 0} · 成功 ${result.successCount || 0} · 失败 ${result.errorCount || 0}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseIdRows(text: string) {
|
||||||
|
return Array.from(new Set(text
|
||||||
|
.split(/\r?\n|,|,|\s+/)
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
function studentTitle(item: TenantStudentItem | null) {
|
function studentTitle(item: TenantStudentItem | null) {
|
||||||
if (!item) return '未选择学生';
|
if (!item) return '未选择学生';
|
||||||
return item.name || item.phone || item.email || item.username || item.userId;
|
return item.name || item.phone || item.email || item.username || item.userId;
|
||||||
@@ -84,6 +96,7 @@ export default function TenantStudentsPage() {
|
|||||||
const [classes, setClasses] = useState<TenantClassItem[]>([]);
|
const [classes, setClasses] = useState<TenantClassItem[]>([]);
|
||||||
const [students, setStudents] = useState<TenantStudentItem[]>([]);
|
const [students, setStudents] = useState<TenantStudentItem[]>([]);
|
||||||
const [teachers, setTeachers] = useState<TenantTeacherItem[]>([]);
|
const [teachers, setTeachers] = useState<TenantTeacherItem[]>([]);
|
||||||
|
const [crmAssignees, setCrmAssignees] = useState<TenantMemberItem[]>([]);
|
||||||
const [notes, setNotes] = useState<TenantStudentNoteItem[]>([]);
|
const [notes, setNotes] = useState<TenantStudentNoteItem[]>([]);
|
||||||
const [followups, setFollowups] = useState<TenantStudentFollowupItem[]>([]);
|
const [followups, setFollowups] = useState<TenantStudentFollowupItem[]>([]);
|
||||||
const [selectedClassId, setSelectedClassId] = useState('');
|
const [selectedClassId, setSelectedClassId] = useState('');
|
||||||
@@ -94,8 +107,21 @@ export default function TenantStudentsPage() {
|
|||||||
const [assignText, setAssignText] = useState('');
|
const [assignText, setAssignText] = useState('');
|
||||||
const [noteForm, setNoteForm] = useState({ noteType: 'learning', content: '', visibility: 'tenant_staff', isPinned: false });
|
const [noteForm, setNoteForm] = useState({ noteType: 'learning', content: '', visibility: 'tenant_staff', isPinned: false });
|
||||||
const [followupForm, setFollowupForm] = useState({ title: '', description: '', followupType: 'learning', priority: 'normal', status: 'open', dueAt: '', assignedToUserId: '', classId: '' });
|
const [followupForm, setFollowupForm] = useState({ title: '', description: '', followupType: 'learning', priority: 'normal', status: 'open', dueAt: '', assignedToUserId: '', classId: '' });
|
||||||
|
const [crmPushText, setCrmPushText] = useState('');
|
||||||
|
const [crmPushForm, setCrmPushForm] = useState<TenantStudentCrmPushInput>({
|
||||||
|
studentUserIds: [],
|
||||||
|
source: 'manual_batch',
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
followupType: 'sales',
|
||||||
|
priority: 'normal',
|
||||||
|
assignedToUserId: '',
|
||||||
|
classId: '',
|
||||||
|
dueAt: '',
|
||||||
|
});
|
||||||
const [bulkResult, setBulkResult] = useState<BulkOperationResult | null>(null);
|
const [bulkResult, setBulkResult] = useState<BulkOperationResult | null>(null);
|
||||||
const [assignResult, setAssignResult] = useState<BulkOperationResult | null>(null);
|
const [assignResult, setAssignResult] = useState<BulkOperationResult | null>(null);
|
||||||
|
const [crmPushResult, setCrmPushResult] = useState<BulkOperationResult | null>(null);
|
||||||
const [scoped, setScoped] = useState(false);
|
const [scoped, setScoped] = useState(false);
|
||||||
const [busy, setBusy] = useState('');
|
const [busy, setBusy] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -110,6 +136,7 @@ export default function TenantStudentsPage() {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
loadTenantClasses(80).catch(() => ({ items: [] })),
|
loadTenantClasses(80).catch(() => ({ items: [] })),
|
||||||
loadTenantTeachers({ limit: 80 }).catch(() => ({ items: [] })),
|
loadTenantTeachers({ limit: 80 }).catch(() => ({ items: [] })),
|
||||||
|
loadTenantMembers({ status: 'active', limit: 80 }).catch(() => ({ items: [] })),
|
||||||
loadTenantStudents({
|
loadTenantStudents({
|
||||||
classId: nextClassId || undefined,
|
classId: nextClassId || undefined,
|
||||||
keyword: nextKeyword || undefined,
|
keyword: nextKeyword || undefined,
|
||||||
@@ -117,9 +144,10 @@ export default function TenantStudentsPage() {
|
|||||||
limit: 80,
|
limit: 80,
|
||||||
}),
|
}),
|
||||||
loadTenantStudentFollowups({ status: 'open', limit: 30 }).catch(() => ({ items: [] })),
|
loadTenantStudentFollowups({ status: 'open', limit: 30 }).catch(() => ({ items: [] })),
|
||||||
]).then(([classPayload, teacherPayload, studentPayload, followupPayload]) => {
|
]).then(([classPayload, teacherPayload, memberPayload, studentPayload, followupPayload]) => {
|
||||||
setClasses(classPayload.items || []);
|
setClasses(classPayload.items || []);
|
||||||
setTeachers(teacherPayload.items || []);
|
setTeachers(teacherPayload.items || []);
|
||||||
|
setCrmAssignees((memberPayload.items || []).filter(item => crmAssignableRoles.includes(String(item.role || ''))));
|
||||||
setStudents(studentPayload.items || []);
|
setStudents(studentPayload.items || []);
|
||||||
setScoped(studentPayload.scoped === true);
|
setScoped(studentPayload.scoped === true);
|
||||||
setFollowups(followupPayload.items || []);
|
setFollowups(followupPayload.items || []);
|
||||||
@@ -155,6 +183,12 @@ export default function TenantStudentsPage() {
|
|||||||
status: item.status || 'active',
|
status: item.status || 'active',
|
||||||
});
|
});
|
||||||
setFollowupForm(prev => ({ ...prev, studentUserId: item.userId, classId: selectedClassId || String((item.classes || [])[0]?.classId || '') } as typeof prev));
|
setFollowupForm(prev => ({ ...prev, studentUserId: item.userId, classId: selectedClassId || String((item.classes || [])[0]?.classId || '') } as typeof prev));
|
||||||
|
setCrmPushText(item.userId);
|
||||||
|
setCrmPushForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
studentUserIds: [item.userId],
|
||||||
|
classId: selectedClassId || String((item.classes || [])[0]?.classId || ''),
|
||||||
|
}));
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [notePayload, followupPayload] = await Promise.all([
|
const [notePayload, followupPayload] = await Promise.all([
|
||||||
@@ -259,6 +293,40 @@ export default function TenantStudentsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitCrmPush() {
|
||||||
|
const studentUserIds = parseIdRows(crmPushText);
|
||||||
|
if (!studentUserIds.length) {
|
||||||
|
Taro.showToast({ title: '请填写学生ID', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!crmPushForm.title?.trim()) {
|
||||||
|
Taro.showToast({ title: '请填写任务标题', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy('crmPush');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = await pushTenantStudentsToCrm({
|
||||||
|
...crmPushForm,
|
||||||
|
studentUserIds,
|
||||||
|
title: crmPushForm.title.trim(),
|
||||||
|
message: crmPushForm.message?.trim() || null,
|
||||||
|
assignedToUserId: crmPushForm.assignedToUserId || null,
|
||||||
|
classId: crmPushForm.classId || selectedClassId || null,
|
||||||
|
dueAt: crmPushForm.dueAt || null,
|
||||||
|
metadata: { source: 'taro-tenant-admin' },
|
||||||
|
idempotencyKey: `taro-${Date.now()}`,
|
||||||
|
});
|
||||||
|
setCrmPushResult(result);
|
||||||
|
Taro.showToast({ title: 'CRM推送已入队', icon: result.errorCount ? 'none' : 'success' });
|
||||||
|
reload();
|
||||||
|
} catch (nextError) {
|
||||||
|
setError(nextError instanceof Error ? nextError.message : 'CRM推送失败');
|
||||||
|
} finally {
|
||||||
|
setBusy('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveNote() {
|
async function saveNote() {
|
||||||
if (!selectedStudent) {
|
if (!selectedStudent) {
|
||||||
Taro.showToast({ title: '请选择学生', icon: 'none' });
|
Taro.showToast({ title: '请选择学生', icon: 'none' });
|
||||||
@@ -470,6 +538,46 @@ export default function TenantStudentsPage() {
|
|||||||
{!students.length ? <View className='admin-empty'>暂无学生,或当前角色没有可见学生范围。</View> : null}
|
{!students.length ? <View className='admin-empty'>暂无学生,或当前角色没有可见学生范围。</View> : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className='admin-section'>
|
||||||
|
<Text className='admin-section-title'>CRM 批量推送</Text>
|
||||||
|
<View className='admin-form-grid'>
|
||||||
|
<Input className='admin-input' placeholder='跟进标题' value={crmPushForm.title} onInput={event => setCrmPushForm(prev => ({ ...prev, title: String(event.detail.value || '') }))} />
|
||||||
|
<Input className='admin-input' placeholder='到期时间,例如 2026-07-01T10:00:00.000Z' value={crmPushForm.dueAt || ''} onInput={event => setCrmPushForm(prev => ({ ...prev, dueAt: String(event.detail.value || '') }))} />
|
||||||
|
</View>
|
||||||
|
<Textarea
|
||||||
|
className='admin-textarea'
|
||||||
|
placeholder='每行一个学生 userId'
|
||||||
|
value={crmPushText}
|
||||||
|
onInput={event => setCrmPushText(String(event.detail.value || ''))}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
className='admin-textarea'
|
||||||
|
placeholder='推送说明'
|
||||||
|
value={crmPushForm.message || ''}
|
||||||
|
onInput={event => setCrmPushForm(prev => ({ ...prev, message: String(event.detail.value || '') }))}
|
||||||
|
/>
|
||||||
|
<View className='admin-actions compact'>
|
||||||
|
{followupTypes.map(item => <Button key={item} className={`admin-button ${crmPushForm.followupType === item ? 'active' : ''}`} onClick={() => setCrmPushForm(prev => ({ ...prev, followupType: item }))}>{item}</Button>)}
|
||||||
|
</View>
|
||||||
|
<View className='admin-actions compact'>
|
||||||
|
{followupPriorities.map(item => <Button key={item} className={`admin-button ${crmPushForm.priority === item ? 'active' : ''}`} onClick={() => setCrmPushForm(prev => ({ ...prev, priority: item }))}>{item}</Button>)}
|
||||||
|
</View>
|
||||||
|
<View className='admin-actions compact'>
|
||||||
|
<Button className={`admin-button ${crmPushForm.assignedToUserId ? '' : 'active'}`} onClick={() => setCrmPushForm(prev => ({ ...prev, assignedToUserId: '' }))}>不指派</Button>
|
||||||
|
{crmAssignees.slice(0, 12).map(item => (
|
||||||
|
<Button key={item.userId} className={`admin-button ${crmPushForm.assignedToUserId === item.userId ? 'active' : ''}`} onClick={() => setCrmPushForm(prev => ({ ...prev, assignedToUserId: item.userId }))}>{item.name || item.phone || item.username || '成员'}</Button>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<View className='admin-actions compact'>
|
||||||
|
<Button className='admin-button primary' loading={busy === 'crmPush'} onClick={submitCrmPush}>入队推送</Button>
|
||||||
|
{selectedStudent ? <Button className='admin-button' onClick={() => setCrmPushText(selectedStudent.userId)}>填入当前学生</Button> : null}
|
||||||
|
{crmPushResult ? <Text className='admin-row-meta'>{resultSummary(crmPushResult)}</Text> : null}
|
||||||
|
</View>
|
||||||
|
{crmPushResult?.errors?.slice(0, 3).map(item => (
|
||||||
|
<Text className='admin-row-meta break-line' key={`${item.index}:${item.code}`}>第 {Number(item.index || 0) + 1} 行 · {item.code} · {item.message}</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='admin-section'>
|
<View className='admin-section'>
|
||||||
<Text className='admin-section-title'>学生跟进</Text>
|
<Text className='admin-section-title'>学生跟进</Text>
|
||||||
<View className='admin-row'>
|
<View className='admin-row'>
|
||||||
|
|||||||
@@ -124,6 +124,20 @@ export interface TenantStudentFollowupItem {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TenantStudentCrmPushInput {
|
||||||
|
studentUserIds: string[];
|
||||||
|
source?: string;
|
||||||
|
title: string;
|
||||||
|
message?: string | null;
|
||||||
|
followupType?: string;
|
||||||
|
priority?: string;
|
||||||
|
assignedToUserId?: string | null;
|
||||||
|
classId?: string | null;
|
||||||
|
dueAt?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImportJobItem {
|
export interface ImportJobItem {
|
||||||
id: string;
|
id: string;
|
||||||
importType?: string;
|
importType?: string;
|
||||||
@@ -985,6 +999,13 @@ export async function updateTenantStudentStatus(input: { userId: string; status:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pushTenantStudentsToCrm(input: TenantStudentCrmPushInput) {
|
||||||
|
return apiRequest<BulkOperationResult & { requestId?: string }>('/api/tenant-admin/students/crm-push', {
|
||||||
|
method: 'POST',
|
||||||
|
body: input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function bulkAssignTenantClassMembers(input: {
|
export async function bulkAssignTenantClassMembers(input: {
|
||||||
classId: string;
|
classId: string;
|
||||||
assignments: Array<{
|
assignments: Array<{
|
||||||
|
|||||||
@@ -145,6 +145,64 @@ function compactLeadPayload(task: CrmQueueRow, configRow: CrmConfigRow | null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function arrayValue(value: unknown) {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactStudentCrmPushPayload(task: CrmQueueRow, configRow: CrmConfigRow | null) {
|
||||||
|
const payload = objectValue(task.payload);
|
||||||
|
const student = objectValue(payload.student);
|
||||||
|
const assignee = objectValue(payload.assignee);
|
||||||
|
const classInfo = objectValue(payload.class);
|
||||||
|
const followup = objectValue(payload.followup);
|
||||||
|
const classes = arrayValue(student.classes).slice(0, 5).map(item => {
|
||||||
|
const classItem = objectValue(item);
|
||||||
|
return {
|
||||||
|
classId: stringValue(classItem.classId),
|
||||||
|
className: stringValue(classItem.className),
|
||||||
|
classCode: stringValue(classItem.classCode),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
formName: stringValue(payload.formName, configRow?.formName || '刷题题库'),
|
||||||
|
examType: stringValue(payload.examType, configRow?.examType || '成人本科'),
|
||||||
|
source: stringValue(payload.source, task.source || 'manual_batch'),
|
||||||
|
title: stringValue(payload.title, '学生跟进提醒'),
|
||||||
|
message: stringValue(payload.message),
|
||||||
|
student: {
|
||||||
|
id: stringValue(student.userId) || stringValue(student.id, task.recordId || ''),
|
||||||
|
username: stringValue(student.username),
|
||||||
|
name: stringValue(student.name) || stringValue(student.username) || '未填写',
|
||||||
|
phone: stringValue(student.phone),
|
||||||
|
email: stringValue(student.email),
|
||||||
|
avatarPreset: stringValue(student.avatarPreset, 'male'),
|
||||||
|
regionName: stringValue(student.regionName),
|
||||||
|
selectedSchoolName: stringValue(student.selectedSchoolName),
|
||||||
|
selectedMajorName: stringValue(student.selectedMajorName),
|
||||||
|
classes,
|
||||||
|
},
|
||||||
|
assignee: stringValue(assignee.userId) || stringValue(assignee.id) ? {
|
||||||
|
id: stringValue(assignee.userId) || stringValue(assignee.id),
|
||||||
|
name: stringValue(assignee.name) || stringValue(assignee.username),
|
||||||
|
role: stringValue(assignee.role),
|
||||||
|
} : null,
|
||||||
|
class: stringValue(classInfo.classId) ? {
|
||||||
|
classId: stringValue(classInfo.classId),
|
||||||
|
className: stringValue(classInfo.className),
|
||||||
|
classCode: stringValue(classInfo.classCode),
|
||||||
|
} : null,
|
||||||
|
followup: {
|
||||||
|
id: stringValue(followup.id),
|
||||||
|
title: stringValue(followup.title) || stringValue(payload.title),
|
||||||
|
followupType: stringValue(followup.followupType),
|
||||||
|
priority: stringValue(followup.priority),
|
||||||
|
dueAt: stringValue(followup.dueAt),
|
||||||
|
},
|
||||||
|
metadata: objectValue(payload.metadata),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function leadMarkdown(task: CrmQueueRow, configRow: CrmConfigRow | null) {
|
function leadMarkdown(task: CrmQueueRow, configRow: CrmConfigRow | null) {
|
||||||
const payload = compactLeadPayload(task, configRow);
|
const payload = compactLeadPayload(task, configRow);
|
||||||
const student = payload.student;
|
const student = payload.student;
|
||||||
@@ -162,6 +220,36 @@ function leadMarkdown(task: CrmQueueRow, configRow: CrmConfigRow | null) {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function studentCrmPushMarkdown(task: CrmQueueRow, configRow: CrmConfigRow | null) {
|
||||||
|
const payload = compactStudentCrmPushPayload(task, configRow);
|
||||||
|
const student = payload.student;
|
||||||
|
const target = [student.regionName, student.selectedSchoolName, student.selectedMajorName].filter(Boolean).join(' / ');
|
||||||
|
const classes = student.classes
|
||||||
|
.map(item => item.className || item.classCode)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('、');
|
||||||
|
const lines = [
|
||||||
|
`### ${payload.formName}学生跟进`,
|
||||||
|
`- 考试类型:${payload.examType}`,
|
||||||
|
`- 来源:${payload.source}`,
|
||||||
|
`- 任务:${payload.title}`,
|
||||||
|
`- 学生:${student.name}`,
|
||||||
|
`- 手机:${student.phone || '未填写'}`,
|
||||||
|
`- 意向:${target || '未填写'}`,
|
||||||
|
`- 班级:${payload.class?.className || classes || '无'}`,
|
||||||
|
`- 指派:${payload.assignee?.name || payload.assignee?.id || '未指派'}`,
|
||||||
|
`- 优先级:${payload.followup.priority || 'normal'}`,
|
||||||
|
`- 到期时间:${payload.followup.dueAt || '无'}`,
|
||||||
|
`- 跟进ID:${payload.followup.id || '无'}`,
|
||||||
|
];
|
||||||
|
if (payload.message) lines.splice(4, 0, `- 说明:${payload.message}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskEventType(task: CrmQueueRow) {
|
||||||
|
return stringValue(objectValue(task.payload).eventType, 'lead.created');
|
||||||
|
}
|
||||||
|
|
||||||
function dingtalkSign(secret: string): Record<string, string> {
|
function dingtalkSign(secret: string): Record<string, string> {
|
||||||
if (!secret) return {};
|
if (!secret) return {};
|
||||||
const timestamp = String(Date.now());
|
const timestamp = String(Date.now());
|
||||||
@@ -193,7 +281,10 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
|
|||||||
const target = validateWebhookUrl(task.targetUrl || configRow?.url || '');
|
const target = validateWebhookUrl(task.targetUrl || configRow?.url || '');
|
||||||
const provider = providerFrom(configRow, task);
|
const provider = providerFrom(configRow, task);
|
||||||
const secretValue = secretText(secret, ['secret', 'signSecret', 'webhookSecret']);
|
const secretValue = secretText(secret, ['secret', 'signSecret', 'webhookSecret']);
|
||||||
const markdown = leadMarkdown(task, configRow);
|
const eventType = taskEventType(task);
|
||||||
|
const isStudentPush = eventType === 'student.crm_push';
|
||||||
|
const markdown = isStudentPush ? studentCrmPushMarkdown(task, configRow) : leadMarkdown(task, configRow);
|
||||||
|
const title = isStudentPush ? '学生跟进提醒' : '新客资提醒';
|
||||||
const headers = { 'content-type': 'application/json' };
|
const headers = { 'content-type': 'application/json' };
|
||||||
|
|
||||||
if (provider === 'dingtalk') {
|
if (provider === 'dingtalk') {
|
||||||
@@ -204,7 +295,7 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
|
|||||||
body: {
|
body: {
|
||||||
msgtype: 'markdown',
|
msgtype: 'markdown',
|
||||||
markdown: {
|
markdown: {
|
||||||
title: '新客资提醒',
|
title,
|
||||||
text: markdown,
|
text: markdown,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -221,7 +312,7 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
|
|||||||
...feishuSign(secretValue),
|
...feishuSign(secretValue),
|
||||||
card: {
|
card: {
|
||||||
config: { wide_screen_mode: true },
|
config: { wide_screen_mode: true },
|
||||||
header: { title: { tag: 'plain_text', content: '新客资提醒' }, template: 'blue' },
|
header: { title: { tag: 'plain_text', content: title }, template: isStudentPush ? 'wathet' : 'blue' },
|
||||||
elements: [{ tag: 'markdown', content: markdown }],
|
elements: [{ tag: 'markdown', content: markdown }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -244,7 +335,10 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
|
|||||||
provider,
|
provider,
|
||||||
url: target.toString(),
|
url: target.toString(),
|
||||||
headers,
|
headers,
|
||||||
body: {
|
body: isStudentPush ? {
|
||||||
|
event: 'student.crm_push',
|
||||||
|
data: compactStudentCrmPushPayload(task, configRow),
|
||||||
|
} : {
|
||||||
event: 'lead.created',
|
event: 'lead.created',
|
||||||
data: compactLeadPayload(task, configRow),
|
data: compactLeadPayload(task, configRow),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -144,7 +144,7 @@
|
|||||||
| 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches`、`activation-codes` |
|
| 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches`、`activation-codes` |
|
||||||
| 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members`、`permissions`、`role-templates`、`audit-logs` |
|
| 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members`、`permissions`、`role-templates`、`audit-logs` |
|
||||||
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 |
|
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 |
|
||||||
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验;学生导入/upsert/分班禁止 `avatarUrl/avatar_url/avatar/headimgurl/figureurl` 和 `primaryRole/primary_role`,避免绕过预设头像与租户角色体系 |
|
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`、`students/crm-push`;支持逐行结果、限量、防跨租户和教师范围校验;批量 CRM 推送会为学生生成跟进任务并写入异步 CRM 队列,要求 `crm:write`、`students:read`、`students:followups:write`;学生导入/upsert/分班禁止 `avatarUrl/avatar_url/avatar/headimgurl/figureurl` 和 `primaryRole/primary_role`,避免绕过预设头像与租户角色体系 |
|
||||||
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
|
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
|
||||||
| 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、平台员工列表、平台员工创建/编辑、平台员工禁用/恢复、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量;平台 API 已拆分 `platform:staff:read/write/status`、`platform:tenant:read/write/status/billing_profile`、`platform:billing:read/write/payment/dunning/notification`、`platform:audit:read/export/alert/notification`、`platform:question_bank:read/grant/ops` 等权限点;审计导出、平台员工操作、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook,发送成功会推进提醒状态,失败会退避重试,联系方式和请求 payload 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 |
|
| 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、平台员工列表、平台员工创建/编辑、平台员工禁用/恢复、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量;平台 API 已拆分 `platform:staff:read/write/status`、`platform:tenant:read/write/status/billing_profile`、`platform:billing:read/write/payment/dunning/notification`、`platform:audit:read/export/alert/notification`、`platform:question_bank:read/grant/ops` 等权限点;审计导出、平台员工操作、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook,发送成功会推进提醒状态,失败会退避重试,联系方式和请求 payload 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 |
|
||||||
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
||||||
@@ -162,8 +162,8 @@
|
|||||||
| 手工补绑 | 可联调 | 需要 `referral:write` |
|
| 手工补绑 | 可联调 | 需要 `referral:write` |
|
||||||
| 销售统计/客户列表/团队 | 可联调 | `/api/referral/sales-*`、`team` |
|
| 销售统计/客户列表/团队 | 可联调 | `/api/referral/sales-*`、`team` |
|
||||||
| CRM 配置/队列 | 可联调 | `/api/crm/config`、`/api/crm/queue`;CRM 配置支持 `none/direct/round_robin/referrer` 客资跟进分配策略、租户内候选成员校验、轮询游标、分配审计,客资首绑成功后会写入 `assignedToUserId` 并进入队列 payload |
|
| CRM 配置/队列 | 可联调 | `/api/crm/config`、`/api/crm/queue`;CRM 配置支持 `none/direct/round_robin/referrer` 客资跟进分配策略、租户内候选成员校验、轮询游标、分配审计,客资首绑成功后会写入 `assignedToUserId` 并进入队列 payload |
|
||||||
| CRM webhook worker | 可联调 | `apps/worker` 已支持 generic webhook、钉钉、飞书、企微群机器人消息体/签名、到期任务消费、失败退避重试、最终失败、discarded 和 `crm_webhook_log` |
|
| CRM webhook worker | 可联调 | `apps/worker` 已支持 generic webhook、钉钉、飞书、企微群机器人消息体/签名、`lead.created` 客资首绑事件和 `student.crm_push` 学生运营跟进事件、到期任务消费、失败退避重试、最终失败、discarded 和 `crm_webhook_log` |
|
||||||
| CRM 增强 | 待补齐 | 富卡片模板、失败告警、死信运营后台、批量 CRM 推送和更细销售转化看板 |
|
| CRM 增强 | 待补齐 | 富卡片模板、失败告警、死信运营后台、跟进效果统计和更细销售转化看板 |
|
||||||
| 分佣结算基础闭环 | 可联调 | `/api/commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`、`settlements/export`、`settlements/proofs`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、CSV/JSON 导出、打款凭证登记/复核、销售/代理本人范围和租户隔离 |
|
| 分佣结算基础闭环 | 可联调 | `/api/commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`、`settlements/export`、`settlements/proofs`;支持订单/激活码归因、批次/成员/默认比例优先级、北京时间账期、结算单生成、审核、打款状态、已打款锁定、CSV/JSON 导出、打款凭证登记/复核、销售/代理本人范围和租户隔离 |
|
||||||
| 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款 provider、发票管理、批量凭证上传、异常调整单和分佣看板 |
|
| 分佣打款增强 | 待补齐 | 银行/微信/支付宝真实打款 provider、发票管理、批量凭证上传、异常调整单和分佣看板 |
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者、日期和关键词查询平台审计;`GET /api/platform-admin/audit-logs/export` 支持平台管理员导出 CSV/JSON,返回 base64 内容、sha256、行数和筛选条件,并对 `details` 中的 token/secret/password/key 等敏感字段递归脱敏,同时写入 `platform.audit.exported` 审计;`GET /api/platform-admin/audit-alert-rules`、`GET /api/platform-admin/audit-alerts` 和 `POST /api/platform-admin/audit-alerts/status` 支持平台内部审计告警规则查询、开放告警查询、确认/解决/忽略,API 返回告警 details 时递归脱敏敏感字段;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`GET/PUT /api/platform-admin/audit-notification-channels` 和 `GET /api/platform-admin/audit-notification-events` 已支持平台审计告警外部通知渠道配置和发送事件查询,`apps/worker --job platform-audit-notifications` 可按渠道把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥进入 `app_private.platform_secrets`,API 只回显 `secretRef` 和 webhook host/path;`GET /api/platform-admin/invoices/subscription-candidates` 和 `POST /api/platform-admin/invoices/from-subscriptions-batch` 支持订阅账单候选预览、dry-run、批量生成、重复开票跳过和平台审计;`apps/worker --job platform-billing` 可自动为即将到期且未开票订阅生成服务费账单;`POST /api/platform-admin/invoices/process-overdue`、`GET /api/platform-admin/invoices/reminders` 和 `apps/worker --job platform-dunning` 可处理已逾期未结清服务费账单,写入内部催缴台账和审计;`GET/PUT /api/platform-admin/dunning-notification-channels`、`GET /api/platform-admin/dunning-notification-events` 和 `apps/worker --job platform-dunning-notifications` 已支持平台催缴外部通知渠道配置、发送事件查询、重试和幂等发送。创建租户、状态变更、账务资料维护、订阅批量开票、自动计费、逾期催缴、手工收款确认、审计导出、审计告警状态更新、审计告警通知渠道变更和催缴通知渠道变更会写入审计日志,API/worker 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝、敏感字段脱敏和审计记录存在。
|
- 平台后台租户运营第一版已补齐:`GET /api/platform-admin/tenants/detail` 返回租户、域名、订阅、账单、用量和账务资料;`PUT /api/platform-admin/tenants/billing-profile` 维护开票/联系/银行掩码资料;`GET /api/platform-admin/audit-logs` 支持按租户、动作、目标、操作者、日期和关键词查询平台审计;`GET /api/platform-admin/audit-logs/export` 支持平台管理员导出 CSV/JSON,返回 base64 内容、sha256、行数和筛选条件,并对 `details` 中的 token/secret/password/key 等敏感字段递归脱敏,同时写入 `platform.audit.exported` 审计;`GET /api/platform-admin/audit-alert-rules`、`GET /api/platform-admin/audit-alerts` 和 `POST /api/platform-admin/audit-alerts/status` 支持平台内部审计告警规则查询、开放告警查询、确认/解决/忽略,API 返回告警 details 时递归脱敏敏感字段;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`GET/PUT /api/platform-admin/audit-notification-channels` 和 `GET /api/platform-admin/audit-notification-events` 已支持平台审计告警外部通知渠道配置和发送事件查询,`apps/worker --job platform-audit-notifications` 可按渠道把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥进入 `app_private.platform_secrets`,API 只回显 `secretRef` 和 webhook host/path;`GET /api/platform-admin/invoices/subscription-candidates` 和 `POST /api/platform-admin/invoices/from-subscriptions-batch` 支持订阅账单候选预览、dry-run、批量生成、重复开票跳过和平台审计;`apps/worker --job platform-billing` 可自动为即将到期且未开票订阅生成服务费账单;`POST /api/platform-admin/invoices/process-overdue`、`GET /api/platform-admin/invoices/reminders` 和 `apps/worker --job platform-dunning` 可处理已逾期未结清服务费账单,写入内部催缴台账和审计;`GET/PUT /api/platform-admin/dunning-notification-channels`、`GET /api/platform-admin/dunning-notification-events` 和 `apps/worker --job platform-dunning-notifications` 已支持平台催缴外部通知渠道配置、发送事件查询、重试和幂等发送。创建租户、状态变更、账务资料维护、订阅批量开票、自动计费、逾期催缴、手工收款确认、审计导出、审计告警状态更新、审计告警通知渠道变更和催缴通知渠道变更会写入审计日志,API/worker 集成测试已覆盖平台管理员可操作、学生越权拒绝、重复保护、非法输入拒绝、敏感字段脱敏和审计记录存在。
|
||||||
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
|
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
|
||||||
- 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
- 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||||
- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
|
- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复和学生批量 CRM 推送也已接入权限校验、范围校验和集成测试。
|
||||||
- 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转和 7/30/90 天只读反馈运营报表;`user_score_events` 支持每日签到积分流水、租户配置化连续签到奖励和反馈奖励幂等;`badges/user_badges` 支持租户后台勋章维护、手动发放、重复发放幂等和学生个人中心展示;`user_notifications` 支持反馈处理、反馈奖励、勋章发放和积分兑换的学生站内通知;Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接反馈运营摘要、积分任务/每日签到奖励/兑换操作台和用户通知查看第一版。
|
- 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转和 7/30/90 天只读反馈运营报表;`user_score_events` 支持每日签到积分流水、租户配置化连续签到奖励和反馈奖励幂等;`badges/user_badges` 支持租户后台勋章维护、手动发放、重复发放幂等和学生个人中心展示;`user_notifications` 支持反馈处理、反馈奖励、勋章发放和积分兑换的学生站内通知;Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接反馈运营摘要、积分任务/每日签到奖励/兑换操作台和用户通知查看第一版。
|
||||||
- 旧题库排行榜主链路已补齐但租户默认关闭:`GET /api/learning/leaderboard` 只有在 `feature_flags.enableLeaderboard=true` 时可用,支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。
|
- 旧题库排行榜主链路已补齐但租户默认关闭:`GET /api/learning/leaderboard` 只有在 `feature_flags.enableLeaderboard=true` 时可用,支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。
|
||||||
- `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
|
- `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
|
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
|
||||||
- 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。
|
- 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。
|
||||||
- 已新增 `npm run test:api`,自动 seed、构建、启动临时 API,并断言核心学生端接口、内容导航/组卷、租户隔离、资源权限和题目导入。
|
- 已新增 `npm run test:api`,自动 seed、构建、启动临时 API,并断言核心学生端接口、内容导航/组卷、租户隔离、资源权限和题目导入。
|
||||||
- 已新增 `apps/worker` 和 `npm run test:worker:crm`,用于消费 CRM webhook 队列,验证本地 fake webhook、队列状态、日志和密钥不泄露。
|
- 已新增 `apps/worker` 和 `npm run test:worker:crm`,用于消费 CRM webhook 队列,验证本地 fake webhook、队列状态、日志和密钥不泄露;worker 已支持 `lead.created` 客资首绑事件和 `student.crm_push` 学生运营跟进事件。
|
||||||
- 已新增 commerce worker 和 `npm run test:worker:commerce`,用于补偿查询微信/支付宝支付、处理中退款和漏通知场景;支付成功会幂等更新订单/支付并开通权益,退款成功会幂等更新退款/订单/支付并在全额退款时撤销订单权益,测试覆盖密钥不泄露和重复执行不重复开通。
|
- 已新增 commerce worker 和 `npm run test:worker:commerce`,用于补偿查询微信/支付宝支付、处理中退款和漏通知场景;支付成功会幂等更新订单/支付并开通权益,退款成功会幂等更新退款/订单/支付并在全额退款时撤销订单权益,测试覆盖密钥不泄露和重复执行不重复开通。
|
||||||
- 已新增 platform-billing worker 和 `npm run test:worker:platform-billing`,用于自动处理即将到期且未开票的 SaaS 订阅;worker 使用订阅行锁和账单查重保证幂等,自动生成 `tenant_invoices/tenant_invoice_items` 并写 `platform.invoice.subscription_auto_created` 审计。
|
- 已新增 platform-billing worker 和 `npm run test:worker:platform-billing`,用于自动处理即将到期且未开票的 SaaS 订阅;worker 使用订阅行锁和账单查重保证幂等,自动生成 `tenant_invoices/tenant_invoice_items` 并写 `platform.invoice.subscription_auto_created` 审计。
|
||||||
- 已新增 platform-dunning worker 和 `npm run test:worker:platform-dunning`,用于扫描已过 `due_date` 且未结清的 SaaS 服务费账单;worker 使用账单行锁和每日唯一催缴约束保证幂等,自动标记 `overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写 `platform.invoice.overdue_processed` 审计。
|
- 已新增 platform-dunning worker 和 `npm run test:worker:platform-dunning`,用于扫描已过 `due_date` 且未结清的 SaaS 服务费账单;worker 使用账单行锁和每日唯一催缴约束保证幂等,自动标记 `overdue`、推送租户 `billing_status=past_due`、生成 `tenant_invoice_reminders` 内部催缴记录并写 `platform.invoice.overdue_processed` 审计。
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# CRM Webhook Worker
|
# CRM Webhook Worker
|
||||||
|
|
||||||
更新时间:2026-06-29
|
更新时间:2026-06-30
|
||||||
|
|
||||||
`apps/worker` 是后端异步任务进程,当前首个落地任务是 CRM 客资 webhook 推送。API 只负责在客资首绑时写入 `crm_webhook_queue`;worker 负责消费到期任务、签名、发送、失败重试和写入 `crm_webhook_log`。
|
`apps/worker` 是后端异步任务进程,当前 CRM 任务支持客资首绑 webhook 推送和租户后台学生批量跟进推送。API 只负责在客资首绑或学生运营动作时写入 `crm_webhook_queue`;worker 负责消费到期任务、签名、发送、失败重试和写入 `crm_webhook_log`。
|
||||||
|
|
||||||
## 运行命令
|
## 运行命令
|
||||||
|
|
||||||
@@ -45,6 +45,7 @@ WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
|
|||||||
PUT /api/crm/config
|
PUT /api/crm/config
|
||||||
GET /api/crm/config
|
GET /api/crm/config
|
||||||
GET /api/crm/queue
|
GET /api/crm/queue
|
||||||
|
POST /api/tenant-admin/students/crm-push
|
||||||
```
|
```
|
||||||
|
|
||||||
密钥必须写入 `app_private.tenant_secrets`,公共表 `crm_config.secret_ref` 只保留引用,例如:
|
密钥必须写入 `app_private.tenant_secrets`,公共表 `crm_config.secret_ref` 只保留引用,例如:
|
||||||
@@ -59,7 +60,7 @@ worker 根据队列 payload 的 `provider` 或 webhook URL 自动判断:
|
|||||||
|
|
||||||
| provider | URL 特征 | 行为 |
|
| provider | URL 特征 | 行为 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `generic` | 默认 | POST `{ event: "lead.created", data: ... }` |
|
| `generic` | 默认 | POST `{ event: "lead.created", data: ... }` 或 `{ event: "student.crm_push", data: ... }` |
|
||||||
| `dingtalk` | `dingtalk.com` | markdown 消息;按钉钉自定义机器人安全设置做 timestamp/sign |
|
| `dingtalk` | `dingtalk.com` | markdown 消息;按钉钉自定义机器人安全设置做 timestamp/sign |
|
||||||
| `feishu` | `feishu.cn` / `larksuite.com` | interactive card;按飞书自定义机器人签名做 timestamp/sign |
|
| `feishu` | `feishu.cn` / `larksuite.com` | interactive card;按飞书自定义机器人签名做 timestamp/sign |
|
||||||
| `wecom` | `qyapi.weixin.qq.com` / `work.weixin.qq.com` | 企业微信群机器人 markdown 消息 |
|
| `wecom` | `qyapi.weixin.qq.com` / `work.weixin.qq.com` | 企业微信群机器人 markdown 消息 |
|
||||||
@@ -95,13 +96,14 @@ pending -> discarded
|
|||||||
|
|
||||||
- 前端不能直接写 `crm_webhook_queue`。
|
- 前端不能直接写 `crm_webhook_queue`。
|
||||||
- 前端不能持有 CRM webhook secret。
|
- 前端不能持有 CRM webhook secret。
|
||||||
|
- 学生批量推送必须走 `POST /api/tenant-admin/students/crm-push`,后端会校验 `crm:write`、`students:read`、`students:followups:write`、学生范围、班级范围和租户成员指派人。
|
||||||
|
- 学生推送 payload 只包含 `avatarPreset` 默认头像口径,不写入、不回显微信/QQ/旧库头像 URL。
|
||||||
- 生产环境 webhook 必须使用 HTTPS。
|
- 生产环境 webhook 必须使用 HTTPS。
|
||||||
- `WORKER_CRM_ALLOW_INSECURE_LOCALHOST=true` 只用于本地 fake webhook 测试。
|
- `WORKER_CRM_ALLOW_INSECURE_LOCALHOST=true` 只用于本地 fake webhook 测试。
|
||||||
- worker 使用数据库服务端连接读取 `app_private.tenant_secrets`,不要把 secret 复制到公共表。
|
- worker 使用数据库服务端连接读取 `app_private.tenant_secrets`,不要把 secret 复制到公共表。
|
||||||
|
|
||||||
## 后续增强
|
## 后续增强
|
||||||
|
|
||||||
- 轮询/定向分配销售。
|
|
||||||
- 钉钉/飞书/企微富卡片模板。
|
- 钉钉/飞书/企微富卡片模板。
|
||||||
- 失败告警和死信运营后台。
|
- 失败告警和死信运营后台。
|
||||||
- 批量 CRM 推送、跟进效果统计。
|
- 跟进效果统计和销售转化看板。
|
||||||
|
|||||||
@@ -94,13 +94,13 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 工作台 | `apps/taro/src/pages/tenant-admin/workbench/index.tsx` | `tenant-admin/overview`、`tenant-admin/dashboard`、`tenant-admin/permissions` |
|
| 工作台 | `apps/taro/src/pages/tenant-admin/workbench/index.tsx` | `tenant-admin/overview`、`tenant-admin/dashboard`、`tenant-admin/permissions` |
|
||||||
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` |
|
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` |
|
||||||
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/teachers`、`tenant-admin/students`、`students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups` |
|
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/teachers`、`tenant-admin/students`、`students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`、`students/crm-push` |
|
||||||
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/exports/questions`、`tenant-content/exports/jobs`、`tenant-content/assets/sign-download`、`tenant-content/assets/sign-preview`、`tenant-content/assets/security-scan-events`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`、`tenant-content/notifications/status` |
|
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/exports/questions`、`tenant-content/exports/jobs`、`tenant-content/assets/sign-download`、`tenant-content/assets/sign-preview`、`tenant-content/assets/security-scan-events`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`、`tenant-content/notifications/status` |
|
||||||
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/config`、`crm/queue`、`commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`、`tenant-admin/feedbacks/report`、`tenant-admin/point-activity-tasks`、`tenant-admin/point-activity-claims`、`tenant-admin/point-exchange-items`、`tenant-admin/point-exchange-orders`、`tenant-admin/points-risk-report`、`tenant-admin/user-notifications`;已接 CRM、分佣、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置与记录查看、积分风控摘要、用户通知查看第一版 |
|
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/config`、`crm/queue`、`commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`、`tenant-admin/feedbacks/report`、`tenant-admin/point-activity-tasks`、`tenant-admin/point-activity-claims`、`tenant-admin/point-exchange-items`、`tenant-admin/point-exchange-orders`、`tenant-admin/points-risk-report`、`tenant-admin/user-notifications`;已接 CRM、分佣、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置与记录查看、积分风控摘要、用户通知查看第一版 |
|
||||||
| 财务运营 | `apps/taro/src/pages/tenant-admin/finance/index.tsx` | `commerce/refunds`、`commerce/refunds/status`、`commerce/operations/anomalies`、`commerce/reconciliation/batches`、`commerce/reconciliation/items`、`commerce/reconciliation/issues/create`、`commerce/reconciliation/issues`、`commerce/reconciliation/issues/status`、`commerce/reconciliation/provider-bills/request`、`commerce/reconciliation/provider-bills/jobs`、`commerce/adjustment-vouchers`、`commerce/adjustment-vouchers/status`、`commerce/adjustment-vouchers/report` |
|
| 财务运营 | `apps/taro/src/pages/tenant-admin/finance/index.tsx` | `commerce/refunds`、`commerce/refunds/status`、`commerce/operations/anomalies`、`commerce/reconciliation/batches`、`commerce/reconciliation/items`、`commerce/reconciliation/issues/create`、`commerce/reconciliation/issues`、`commerce/reconciliation/issues/status`、`commerce/reconciliation/provider-bills/request`、`commerce/reconciliation/provider-bills/jobs`、`commerce/adjustment-vouchers`、`commerce/adjustment-vouchers/status`、`commerce/adjustment-vouchers/report` |
|
||||||
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`theme-templates`、`theme`、`theme/preview`、`theme/publish`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`theme-templates`、`theme`、`theme/preview`、`theme/publish`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
||||||
|
|
||||||
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置、领取/兑换记录查看、积分风控只读摘要和用户通知查看第一版;财务运营页支持退款状态流、官方账单任务、对账异常、差错工单和调整凭证,且只通过后端命令层写审计与状态;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、主题素材库、真实打款 provider、发票、真实生产账单抽样验收和小程序端兼容。
|
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务、完成跟进和 CRM 入队推送;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则/核销报表、反馈运营摘要、积分任务/兑换配置、领取/兑换记录查看、积分风控只读摘要和用户通知查看第一版;财务运营页支持退款状态流、官方账单任务、对账异常、差错工单和调整凭证,且只通过后端命令层写审计与状态;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补更精细的学生导入模板体验、更细数据范围 UI、主题素材库、真实打款 provider、发票、真实生产账单抽样验收和小程序端兼容。
|
||||||
|
|
||||||
## 已落地的 Taro 平台后台页面
|
## 已落地的 Taro 平台后台页面
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
| 旧功能/组件 | 新后端状态 | 待补齐 |
|
| 旧功能/组件 | 新后端状态 | 待补齐 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务已实现;批量 CRM 推送、补绑、学习督导自动化待补 |
|
| 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务和批量 CRM 推送已实现;补绑、学习督导自动化和跟进效果统计待补 |
|
||||||
| 销售/代理管理 | 部分覆盖 | referral/team/stats 已有;分佣设置、订单/激活码归因、结算单生成、审核、打款状态、CSV/JSON 导出、打款凭证登记/复核和本人/全局权限范围已覆盖;还缺真实打款 provider、发票、批量凭证上传和分佣看板 |
|
| 销售/代理管理 | 部分覆盖 | referral/team/stats 已有;分佣设置、订单/激活码归因、结算单生成、审核、打款状态、CSV/JSON 导出、打款凭证登记/复核和本人/全局权限范围已覆盖;还缺真实打款 provider、发票、批量凭证上传和分佣看板 |
|
||||||
| 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 |
|
| 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 |
|
||||||
| 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 |
|
| 数据看板 | 部分覆盖 | 租户 dashboard 聚合 API 已覆盖收益、注册、答题、活跃、题型、科目、题量、套餐销量、运营动态、24h 活跃和激活码使用;后续补预聚合 worker、销售转化和分佣结算看板 |
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
|
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
|
||||||
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
|
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
|
||||||
| 用户站内通知 | 部分覆盖 | 反馈处理、反馈奖励、勋章发放和积分兑换会写入 `user_notifications`,学生可查/标记状态,租户后台可按权限查看租户内通知;Taro 学生个人中心已接消息筛选/已读/归档第一版,租户营销中心已接用户通知查看第一版 | 独立消息中心增强、外部微信订阅消息/短信和批量统计待补 |
|
| 用户站内通知 | 部分覆盖 | 反馈处理、反馈奖励、勋章发放和积分兑换会写入 `user_notifications`,学生可查/标记状态,租户后台可按权限查看租户内通知;Taro 学生个人中心已接消息筛选/已读/归档第一版,租户营销中心已接用户通知查看第一版 | 独立消息中心增强、外部微信订阅消息/短信和批量统计待补 |
|
||||||
| CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、钉钉/飞书/企微 worker、签名和重试已有;富卡片模板、失败告警、死信运营台和批量推送待补 |
|
| CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、学生批量 CRM 推送、钉钉/飞书/企微 worker、签名和重试已有;富卡片模板、失败告警、死信运营台和跟进效果统计待补 |
|
||||||
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有;租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 |
|
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有;租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 |
|
||||||
|
|
||||||
## 平台 SaaS 后台功能
|
## 平台 SaaS 后台功能
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
- 内容导入:题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、校验、导入、幂等、审计。
|
- 内容导入:题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、校验、导入、幂等、审计。
|
||||||
- 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。
|
- 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。
|
||||||
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||||
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;学生写入口已拒绝头像 URL、第三方头像字段和 `primaryRole`,学生头像统一走男女预设;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。
|
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务、批量 CRM 推送已完成接口和集成测试;学生写入口已拒绝头像 URL、第三方头像字段和 `primaryRole`,学生头像统一走男女预设;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注、跟进任务和 CRM 推送第一版;后续补自动学习督导、跟进效果统计和更细导入模板体验。
|
||||||
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、反馈运营聚合报表、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。
|
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、反馈运营聚合报表、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。
|
||||||
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。学生激励以后台配置勋章自动发放为主,不默认启用排行榜竞争。后续补更细活动效果看板和更细前端运营配置体验。
|
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。学生激励以后台配置勋章自动发放为主,不默认启用排行榜竞争。后续补更细活动效果看板和更细前端运营配置体验。
|
||||||
- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;兑换完成或待履约会写入用户站内通知;Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台、反馈运营摘要和积分风控只读摘要第一版。后续补更细活动效果看板。
|
- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;兑换完成或待履约会写入用户站内通知;Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台、反馈运营摘要和积分风控只读摘要第一版。后续补更细活动效果看板。
|
||||||
@@ -159,9 +159,9 @@
|
|||||||
- 继续补真实打款 provider、发票管理、批量凭证上传、销售团队分佣看板和异常调整单。
|
- 继续补真实打款 provider、发票管理、批量凭证上传、销售团队分佣看板和异常调整单。
|
||||||
|
|
||||||
12. 学生运营管理
|
12. 学生运营管理
|
||||||
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务;学生写入口拒绝头像 URL、第三方头像字段和 `primaryRole`,避免绕过预设头像与租户角色体系。
|
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注、跟进任务和批量 CRM 推送;学生写入口拒绝头像 URL、第三方头像字段和 `primaryRole`,避免绕过预设头像与租户角色体系。
|
||||||
- 已完成 Taro 学生运营页第一版:学生创建/更新、状态禁用/恢复、批量导入、批量分班、备注、跟进任务和完成跟进。
|
- 已完成 Taro 学生运营页第一版:学生创建/更新、状态禁用/恢复、批量导入、批量分班、备注、跟进任务、完成跟进和 CRM 入队推送。
|
||||||
- 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和更细导入模板体验。
|
- 继续补学习督导自动化、跟进效果统计和更细导入模板体验。
|
||||||
|
|
||||||
13. AI 择校推荐
|
13. AI 择校推荐
|
||||||
- 已完成 `ai_recommendation_reports` 报告台账、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON 输出、报告列表/详情接口和 Taro 学生端基础页。
|
- 已完成 `ai_recommendation_reports` 报告台账、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON 输出、报告列表/详情接口和 Taro 学生端基础页。
|
||||||
|
|||||||
@@ -1477,6 +1477,7 @@ content_entries
|
|||||||
- 教师、班主任、助教类账号进入租户后台时,学生列表以 `GET /api/tenant-admin/students` 返回的 `scoped` 和 `items` 为准;前端不要自行用本地班级 ID 放大查询范围。
|
- 教师、班主任、助教类账号进入租户后台时,学生列表以 `GET /api/tenant-admin/students` 返回的 `scoped` 和 `items` 为准;前端不要自行用本地班级 ID 放大查询范围。
|
||||||
- 学生手机号、订单金额、客资归属等敏感字段按 `fieldPermissions` 控制显示;字段被后端返回为 `null` 时前端展示脱敏占位,不要从其它接口补取。
|
- 学生手机号、订单金额、客资归属等敏感字段按 `fieldPermissions` 控制显示;字段被后端返回为 `null` 时前端展示脱敏占位,不要从其它接口补取。
|
||||||
- 学生批量导入和批量分班接口会返回 `total/successCount/errorCount/results`,前端必须展示逐行错误,不要在浏览器端静默丢弃失败行。学生导入模板不要提供头像 URL 或 `primaryRole` 字段;后端会拒绝这类字段,学生头像统一由个人中心 `avatarPreset` 控制。
|
- 学生批量导入和批量分班接口会返回 `total/successCount/errorCount/results`,前端必须展示逐行错误,不要在浏览器端静默丢弃失败行。学生导入模板不要提供头像 URL 或 `primaryRole` 字段;后端会拒绝这类字段,学生头像统一由个人中心 `avatarPreset` 控制。
|
||||||
|
- 学生批量 CRM 推送统一调用 `POST /api/tenant-admin/students/crm-push`,请求包含 `studentUserIds/title/message/followupType/priority/assignedToUserId/classId/dueAt/idempotencyKey`。前端不要直接写 `crm_webhook_queue`,也不要持有 webhook 密钥;后端会校验 `crm:write`、学生范围、班级范围、指派人租户成员和幂等键,并返回逐行 `queueId/followupId/errors`。
|
||||||
- 教师可以为范围内学生创建备注和跟进任务,但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定;前端只按返回值渲染。
|
- 教师可以为范围内学生创建备注和跟进任务,但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定;前端只按返回值渲染。
|
||||||
- H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。
|
- H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。
|
||||||
|
|
||||||
|
|||||||
@@ -9463,6 +9463,102 @@ async function testReferralAndCrmGrowth() {
|
|||||||
assert.deepEqual(crmConfig.item?.assignmentPool, [TENANT_SALES_USER_ID, TENANT_AGENT_USER_ID], 'CRM config should persist assignment pool');
|
assert.deepEqual(crmConfig.item?.assignmentPool, [TENANT_SALES_USER_ID, TENANT_AGENT_USER_ID], 'CRM config should persist assignment pool');
|
||||||
assert.ok(!JSON.stringify(crmConfig).includes('crm-secret-smoke'), 'CRM config response should not leak secret');
|
assert.ok(!JSON.stringify(crmConfig).includes('crm-secret-smoke'), 'CRM config response should not leak secret');
|
||||||
|
|
||||||
|
const studentCrmPush = await request('/api/tenant-admin/students/crm-push', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
studentUserIds: [USER_ID, SECOND_STUDENT_USER_ID],
|
||||||
|
source: 'integration_batch',
|
||||||
|
title: '集成测试批量跟进',
|
||||||
|
message: '请销售今日联系并记录转化情况',
|
||||||
|
followupType: 'sales',
|
||||||
|
priority: 'high',
|
||||||
|
assignedToUserId: TENANT_SALES_USER_ID,
|
||||||
|
dueAt: '2026-07-02T10:00:00.000Z',
|
||||||
|
idempotencyKey: 'integration-student-crm-push',
|
||||||
|
metadata: { source: 'integration-test' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(studentCrmPush.total, 2, 'student CRM push should report total');
|
||||||
|
assert.equal(studentCrmPush.successCount, 2, 'student CRM push should enqueue valid students');
|
||||||
|
assert.equal(studentCrmPush.errorCount, 0, 'student CRM push should not fail valid students');
|
||||||
|
assert.ok(studentCrmPush.items?.every(item => item.queueId && item.followupId), 'student CRM push should return queue and follow-up ids');
|
||||||
|
|
||||||
|
const repeatedStudentCrmPush = await request('/api/tenant-admin/students/crm-push', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
studentUserIds: [USER_ID, SECOND_STUDENT_USER_ID],
|
||||||
|
title: '集成测试批量跟进',
|
||||||
|
message: '重复提交不应重复创建队列',
|
||||||
|
followupType: 'sales',
|
||||||
|
priority: 'high',
|
||||||
|
assignedToUserId: TENANT_SALES_USER_ID,
|
||||||
|
idempotencyKey: 'integration-student-crm-push',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
repeatedStudentCrmPush.items?.map(item => item.queueId).sort(),
|
||||||
|
studentCrmPush.items?.map(item => item.queueId).sort(),
|
||||||
|
'student CRM push idempotency should reuse queue ids',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
repeatedStudentCrmPush.items?.map(item => item.followupId).sort(),
|
||||||
|
studentCrmPush.items?.map(item => item.followupId).sort(),
|
||||||
|
'student CRM push idempotency should reuse follow-up ids',
|
||||||
|
);
|
||||||
|
|
||||||
|
const studentCrmPushClassError = await request('/api/tenant-admin/students/crm-push', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
studentUserIds: [USER_ID, SECOND_STUDENT_USER_ID],
|
||||||
|
title: '集成测试班级范围跟进',
|
||||||
|
classId: ids.tenantClass,
|
||||||
|
idempotencyKey: 'integration-student-crm-push-class-scope',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(studentCrmPushClassError.total, 2, 'student CRM class scoped push should report total');
|
||||||
|
assert.equal(studentCrmPushClassError.successCount, 1, 'student CRM class scoped push should enqueue in-class student');
|
||||||
|
assert.ok(
|
||||||
|
studentCrmPushClassError.errors?.some(item => item.studentUserId === SECOND_STUDENT_USER_ID && item.code === 'STUDENT_CLASS_REQUIRED'),
|
||||||
|
'student CRM class scoped push should reject students outside the selected class',
|
||||||
|
);
|
||||||
|
|
||||||
|
const operatorCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
|
||||||
|
userId: TENANT_OPERATOR_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
studentUserIds: [USER_ID],
|
||||||
|
title: '不应允许普通运营推送',
|
||||||
|
},
|
||||||
|
expectStatus: 403,
|
||||||
|
});
|
||||||
|
assert.equal(operatorCrmPushDenied.code, 'TENANT_PERMISSION_REQUIRED', 'student CRM push should require crm:write permission');
|
||||||
|
|
||||||
|
const partnerStudentCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
|
||||||
|
tenantId: PARTNER_TENANT_ID,
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
studentUserIds: [USER_ID],
|
||||||
|
title: '不应跨租户推送',
|
||||||
|
},
|
||||||
|
expectStatus: 403,
|
||||||
|
});
|
||||||
|
assert.equal(partnerStudentCrmPushDenied.code, 'TENANT_ADMIN_REQUIRED', 'student CRM push must be tenant isolated');
|
||||||
|
|
||||||
|
const studentCrmPushDenied = await request('/api/tenant-admin/students/crm-push', {
|
||||||
|
userId: USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
studentUserIds: [USER_ID],
|
||||||
|
title: '学生端不应推送 CRM',
|
||||||
|
},
|
||||||
|
expectStatus: 403,
|
||||||
|
});
|
||||||
|
assert.equal(studentCrmPushDenied.code, 'TENANT_ADMIN_REQUIRED', 'student CRM push should reject student users');
|
||||||
|
|
||||||
const invalidCrmAssignment = await request('/api/crm/config', {
|
const invalidCrmAssignment = await request('/api/crm/config', {
|
||||||
userId: TENANT_ADMIN_USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -9820,6 +9916,14 @@ async function testReferralAndCrmGrowth() {
|
|||||||
});
|
});
|
||||||
assert.ok(crmQueue.items?.some(item => item.leadId === manual.lead.item.id || item.leadId === tracked.lead.item.id), 'CRM queue should include referral lead task');
|
assert.ok(crmQueue.items?.some(item => item.leadId === manual.lead.item.id || item.leadId === tracked.lead.item.id), 'CRM queue should include referral lead task');
|
||||||
assert.ok(crmQueue.items?.some(item => item.payload?.assignee?.id === TENANT_AGENT_USER_ID), 'CRM queue payload should include assigned follow-up user');
|
assert.ok(crmQueue.items?.some(item => item.payload?.assignee?.id === TENANT_AGENT_USER_ID), 'CRM queue payload should include assigned follow-up user');
|
||||||
|
assert.ok(
|
||||||
|
crmQueue.items?.some(item => item.payload?.eventType === 'student.crm_push' && item.payload?.assignee?.userId === TENANT_SALES_USER_ID),
|
||||||
|
'CRM queue should include student CRM push task',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
crmQueue.items?.some(item => item.payload?.eventType === 'student.crm_push' && item.payload?.student?.avatarPreset && !item.payload?.student?.avatarUrl),
|
||||||
|
'student CRM push payload should use preset avatars only',
|
||||||
|
);
|
||||||
assert.ok(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret');
|
assert.ok(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret');
|
||||||
|
|
||||||
const partnerReferralDenied = await request('/api/referral/sales-stats', {
|
const partnerReferralDenied = await request('/api/referral/sales-stats', {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
drop index if exists public.idx_crm_queue_tenant_record_id;
|
||||||
|
|
||||||
|
create index if not exists idx_crm_queue_tenant_record_id_lookup
|
||||||
|
on public.crm_webhook_queue(tenant_id, record_id, created_at desc)
|
||||||
|
where record_id is not null;
|
||||||
|
|
||||||
|
create index if not exists idx_crm_queue_student_push
|
||||||
|
on public.crm_webhook_queue(tenant_id, source, created_at desc)
|
||||||
|
where source = 'tenant.student.crm_push';
|
||||||
Reference in New Issue
Block a user