feat: add student crm push

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

View File

@@ -1,3 +1,4 @@
import { createHash, randomUUID } from 'node:crypto';
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
@@ -22,13 +23,33 @@ const STUDENT_FOLLOWUP_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
const STUDENT_FOLLOWUP_STATUSES = ['open', 'in_progress', 'done', 'cancelled'];
const MAX_BULK_STUDENTS = 200;
const MAX_BULK_CLASS_ASSIGNMENTS = 500;
const MAX_BULK_CRM_PUSH = 100;
const CRM_ASSIGNABLE_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'];
const STUDENT_AVATAR_FIELD_KEYS = ['avatarUrl', 'avatar_url', 'avatar', 'headimgurl', 'headImgUrl', 'figureurl', 'figureurl_qq_1', 'figureurl_qq_2'];
const STUDENT_PRIMARY_ROLE_FIELD_KEYS = ['primaryRole', 'primary_role'];
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function limitedNullableString(value: unknown, maxLength: number, fieldName: string) {
const candidate = nullableString(value);
if (!candidate) return null;
if (candidate.length > maxLength) {
throw new HttpError(400, `${fieldName} is too long`, 'FIELD_TOO_LONG');
}
return candidate;
}
function requiredLimitedString(body: JsonBody, key: string, maxLength: number) {
const value = requiredString(body, key);
if (value.length > maxLength) {
throw new HttpError(400, `${key} is too long`, 'FIELD_TOO_LONG');
}
return value;
}
function objectValue(value: unknown): Record<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 [];
return value
.map(item => (typeof item === 'string' ? item.trim() : ''))
.filter(item => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(item));
.filter(item => UUID_PATTERN.test(item));
}
function strictUuidArrayValue(value: unknown, fieldName: string, maxLength: number) {
if (!Array.isArray(value)) {
throw new HttpError(400, `${fieldName} must be an array`, 'INVALID_ARRAY_FIELD');
}
if (value.length > maxLength) {
throw new HttpError(413, `${fieldName} supports at most ${maxLength} items`, 'BULK_LIMIT_EXCEEDED');
}
const items: string[] = [];
const seen = new Set<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) {
@@ -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) {
return memberType === 'student' ? 'student' : 'teacher';
}
@@ -1267,3 +1467,209 @@ export async function upsertTenantStudentFollowupRoute(ctx: RequestContext) {
return { item };
}
export async function pushTenantStudentsToCrmRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'crm:write');
requireTenantPermission(auth, 'students:read');
requireTenantPermission(auth, 'students:followups:write');
const body = await readJsonBody(ctx);
const studentUserIds = strictUuidArrayValue(body.studentUserIds || body.userIds, 'studentUserIds', MAX_BULK_CRM_PUSH);
const classId = nullableString(body.classId);
if (classId) await ensureReadableClass(auth, classId);
const source = limitedNullableString(body.source, 80, 'source') || 'manual_batch';
const title = requiredLimitedString(body, 'title', 120);
const message = limitedNullableString(body.message ?? body.description, 1000, 'message');
const followupType = optionalChoice(body.followupType, STUDENT_FOLLOWUP_TYPES, 'sales');
const priority = optionalChoice(body.priority, STUDENT_FOLLOWUP_PRIORITIES, 'normal');
const assignedToUserId = nullableString(body.assignedToUserId);
const dueAt = optionalTimestampString(body.dueAt, 'dueAt');
const metadata = objectValue(body.metadata);
const requestId = randomUUID();
const idempotencyInput = limitedNullableString(body.idempotencyKey, 160, 'idempotencyKey');
const includePhone = canSeeStudentPhone(auth);
const result = await transaction(async client => {
const config = await loadCrmPushConfig(client, auth.tenantId);
if (classId) await ensureTenantClass(client, auth.tenantId, classId);
await ensureUserTenantMembership(client, auth.tenantId, assignedToUserId, CRM_ASSIGNABLE_ROLES, 'ASSIGNEE_NOT_FOUND');
const assignee = await loadCrmAssigneePayload(client, auth.tenantId, assignedToUserId);
const classPayload = await loadCrmClassPayload(client, auth.tenantId, classId);
const delaySeconds = Math.max(0, Number(config.delaySec ?? 60));
const items: unknown[] = [];
const errors: unknown[] = [];
for (let index = 0; index < studentUserIds.length; index += 1) {
const studentUserId = studentUserIds[index];
try {
await ensureStudentInScope(auth, studentUserId);
await ensureStudentInClass(client, auth.tenantId, studentUserId, classId);
const student = await loadStudentCrmPayload(client, auth, studentUserId, includePhone);
const idempotencyKey = crmPushIdempotencyKey(idempotencyInput, requestId, studentUserId);
const followupMetadata = {
...metadata,
crmPush: {
requestId,
idempotencyKey,
source,
},
};
let followup = (await client.query<{
id: string;
studentUserId: string;
assignedToUserId: string | null;
classId: string | null;
title: string;
dueAt: string | null;
createdAt: string;
}>(
`
select id, student_user_id as "studentUserId",
assigned_to_user_id as "assignedToUserId", class_id as "classId",
title, due_at as "dueAt", created_at as "createdAt"
from public.tenant_student_followups
where tenant_id = $1
and student_user_id = $2::uuid
and metadata->'crmPush'->>'idempotencyKey' = $3
limit 1
`,
[auth.tenantId, studentUserId, idempotencyKey],
)).rows[0];
if (!followup) {
followup = (await client.query(
`
insert into public.tenant_student_followups (
tenant_id, student_user_id, assigned_to_user_id, class_id,
title, description, followup_type, priority, status, due_at,
metadata, created_by, updated_by
)
values (
$1, $2::uuid, $3::uuid, $4::uuid,
$5, $6, $7, $8, 'open', $9::timestamptz,
$10::jsonb, $11, $11
)
returning id, student_user_id as "studentUserId",
assigned_to_user_id as "assignedToUserId", class_id as "classId",
title, due_at as "dueAt", created_at as "createdAt"
`,
[
auth.tenantId,
studentUserId,
assignedToUserId,
classId,
title,
message,
followupType,
priority,
dueAt,
JSON.stringify(followupMetadata),
auth.userId,
],
)).rows[0];
}
const payload = {
eventType: 'student.crm_push',
formName: config.formName || '刷题题库',
examType: config.examType || '成人本科',
source,
title,
message,
student,
assignee,
class: classPayload,
followup: {
id: followup.id,
title: followup.title,
followupType,
priority,
dueAt: followup.dueAt,
},
actor: {
userId: auth.userId,
role: auth.role,
},
metadata,
};
const queued = await client.query<{
id: string;
status: string;
scheduledAt: string | null;
idempotencyKey: string;
}>(
`
insert into public.crm_webhook_queue (
tenant_id, record_id, status, scheduled_at, next_attempt_at, lead_id,
source, payload, idempotency_key, target_url
)
values ($1, $2, 'pending', now() + ($3::int * interval '1 second'), null, null, $4, $5::jsonb, $6, $7)
on conflict (tenant_id, idempotency_key) where idempotency_key is not null
do update set status = case
when public.crm_webhook_queue.status = 'sent' then public.crm_webhook_queue.status
else 'pending'
end,
scheduled_at = excluded.scheduled_at,
next_attempt_at = null,
payload = excluded.payload,
target_url = excluded.target_url,
updated_at = now()
returning id, status, scheduled_at as "scheduledAt", idempotency_key as "idempotencyKey"
`,
[
auth.tenantId,
studentUserId,
delaySeconds,
'tenant.student.crm_push',
JSON.stringify(payload),
idempotencyKey,
config.url,
],
);
items.push({
index,
studentUserId,
followupId: followup.id,
queueId: queued.rows[0].id,
queueStatus: queued.rows[0].status,
scheduledAt: queued.rows[0].scheduledAt,
});
} catch (error) {
errors.push({
index,
studentUserId,
code: error instanceof HttpError ? error.code : 'STUDENT_CRM_PUSH_ITEM_FAILED',
message: error instanceof Error ? error.message : 'Student CRM push item failed',
});
}
}
await recordAudit(client, auth, 'tenant.students.crm_pushed', 'crm_webhook_queue', null, {
requestId,
total: studentUserIds.length,
successCount: items.length,
errorCount: errors.length,
source,
followupType,
priority,
assignedToUserId,
classId,
});
return {
requestId,
total: studentUserIds.length,
successCount: items.length,
errorCount: errors.length,
items,
errors,
};
});
return result;
}

View File

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