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],

View File

@@ -9,6 +9,8 @@ import {
loadTenantStudentNotes,
loadTenantStudents,
loadTenantTeachers,
loadTenantMembers,
pushTenantStudentsToCrm,
updateTenantStudentStatus,
upsertTenantStudent,
upsertTenantStudentFollowup,
@@ -19,7 +21,9 @@ import {
type TenantStudentInput,
type TenantStudentItem,
type TenantStudentNoteItem,
type TenantStudentCrmPushInput,
type TenantTeacherItem,
type TenantMemberItem,
} from '@/services/tenantAdmin';
import '../admin.css';
@@ -28,6 +32,7 @@ const noteTypes = ['general', 'learning', 'service', 'sales', 'risk', 'follow_up
const followupTypes = ['learning', 'service', 'sales', 'renewal', 'risk', 'custom'];
const followupPriorities = ['low', 'normal', 'high', 'urgent'];
const followupStatuses = ['open', 'in_progress', 'done', 'cancelled'];
const crmAssignableRoles = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'];
function emptyStudentForm(): TenantStudentInput {
return {
@@ -74,6 +79,13 @@ function resultSummary(result: BulkOperationResult | null) {
return `总数 ${result.total || 0} · 成功 ${result.successCount || 0} · 失败 ${result.errorCount || 0}`;
}
function parseIdRows(text: string) {
return Array.from(new Set(text
.split(/\r?\n|,||\s+/)
.map(item => item.trim())
.filter(Boolean)));
}
function studentTitle(item: TenantStudentItem | null) {
if (!item) return '未选择学生';
return item.name || item.phone || item.email || item.username || item.userId;
@@ -84,6 +96,7 @@ export default function TenantStudentsPage() {
const [classes, setClasses] = useState<TenantClassItem[]>([]);
const [students, setStudents] = useState<TenantStudentItem[]>([]);
const [teachers, setTeachers] = useState<TenantTeacherItem[]>([]);
const [crmAssignees, setCrmAssignees] = useState<TenantMemberItem[]>([]);
const [notes, setNotes] = useState<TenantStudentNoteItem[]>([]);
const [followups, setFollowups] = useState<TenantStudentFollowupItem[]>([]);
const [selectedClassId, setSelectedClassId] = useState('');
@@ -94,8 +107,21 @@ export default function TenantStudentsPage() {
const [assignText, setAssignText] = useState('');
const [noteForm, setNoteForm] = useState({ noteType: 'learning', content: '', visibility: 'tenant_staff', isPinned: false });
const [followupForm, setFollowupForm] = useState({ title: '', description: '', followupType: 'learning', priority: 'normal', status: 'open', dueAt: '', assignedToUserId: '', classId: '' });
const [crmPushText, setCrmPushText] = useState('');
const [crmPushForm, setCrmPushForm] = useState<TenantStudentCrmPushInput>({
studentUserIds: [],
source: 'manual_batch',
title: '',
message: '',
followupType: 'sales',
priority: 'normal',
assignedToUserId: '',
classId: '',
dueAt: '',
});
const [bulkResult, setBulkResult] = useState<BulkOperationResult | null>(null);
const [assignResult, setAssignResult] = useState<BulkOperationResult | null>(null);
const [crmPushResult, setCrmPushResult] = useState<BulkOperationResult | null>(null);
const [scoped, setScoped] = useState(false);
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -110,6 +136,7 @@ export default function TenantStudentsPage() {
Promise.all([
loadTenantClasses(80).catch(() => ({ items: [] })),
loadTenantTeachers({ limit: 80 }).catch(() => ({ items: [] })),
loadTenantMembers({ status: 'active', limit: 80 }).catch(() => ({ items: [] })),
loadTenantStudents({
classId: nextClassId || undefined,
keyword: nextKeyword || undefined,
@@ -117,9 +144,10 @@ export default function TenantStudentsPage() {
limit: 80,
}),
loadTenantStudentFollowups({ status: 'open', limit: 30 }).catch(() => ({ items: [] })),
]).then(([classPayload, teacherPayload, studentPayload, followupPayload]) => {
]).then(([classPayload, teacherPayload, memberPayload, studentPayload, followupPayload]) => {
setClasses(classPayload.items || []);
setTeachers(teacherPayload.items || []);
setCrmAssignees((memberPayload.items || []).filter(item => crmAssignableRoles.includes(String(item.role || ''))));
setStudents(studentPayload.items || []);
setScoped(studentPayload.scoped === true);
setFollowups(followupPayload.items || []);
@@ -155,6 +183,12 @@ export default function TenantStudentsPage() {
status: item.status || 'active',
});
setFollowupForm(prev => ({ ...prev, studentUserId: item.userId, classId: selectedClassId || String((item.classes || [])[0]?.classId || '') } as typeof prev));
setCrmPushText(item.userId);
setCrmPushForm(prev => ({
...prev,
studentUserIds: [item.userId],
classId: selectedClassId || String((item.classes || [])[0]?.classId || ''),
}));
setError('');
try {
const [notePayload, followupPayload] = await Promise.all([
@@ -259,6 +293,40 @@ export default function TenantStudentsPage() {
}
}
async function submitCrmPush() {
const studentUserIds = parseIdRows(crmPushText);
if (!studentUserIds.length) {
Taro.showToast({ title: '请填写学生ID', icon: 'none' });
return;
}
if (!crmPushForm.title?.trim()) {
Taro.showToast({ title: '请填写任务标题', icon: 'none' });
return;
}
setBusy('crmPush');
setError('');
try {
const result = await pushTenantStudentsToCrm({
...crmPushForm,
studentUserIds,
title: crmPushForm.title.trim(),
message: crmPushForm.message?.trim() || null,
assignedToUserId: crmPushForm.assignedToUserId || null,
classId: crmPushForm.classId || selectedClassId || null,
dueAt: crmPushForm.dueAt || null,
metadata: { source: 'taro-tenant-admin' },
idempotencyKey: `taro-${Date.now()}`,
});
setCrmPushResult(result);
Taro.showToast({ title: 'CRM推送已入队', icon: result.errorCount ? 'none' : 'success' });
reload();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : 'CRM推送失败');
} finally {
setBusy('');
}
}
async function saveNote() {
if (!selectedStudent) {
Taro.showToast({ title: '请选择学生', icon: 'none' });
@@ -470,6 +538,46 @@ export default function TenantStudentsPage() {
{!students.length ? <View className='admin-empty'></View> : null}
</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'>
<Text className='admin-section-title'></Text>
<View className='admin-row'>

View File

@@ -124,6 +124,20 @@ export interface TenantStudentFollowupItem {
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 {
id: 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: {
classId: string;
assignments: Array<{

View File

@@ -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) {
const payload = compactLeadPayload(task, configRow);
const student = payload.student;
@@ -162,6 +220,36 @@ function leadMarkdown(task: CrmQueueRow, configRow: CrmConfigRow | null) {
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> {
if (!secret) return {};
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 provider = providerFrom(configRow, task);
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' };
if (provider === 'dingtalk') {
@@ -204,7 +295,7 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
body: {
msgtype: 'markdown',
markdown: {
title: '新客资提醒',
title,
text: markdown,
},
},
@@ -221,7 +312,7 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
...feishuSign(secretValue),
card: {
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 }],
},
},
@@ -244,7 +335,10 @@ function prepareRequest(task: CrmQueueRow, configRow: CrmConfigRow | null, secre
provider,
url: target.toString(),
headers,
body: {
body: isStudentPush ? {
event: 'student.crm_push',
data: compactStudentCrmPushPayload(task, configRow),
} : {
event: 'lead.created',
data: compactLeadPayload(task, configRow),
},