diff --git a/README.md b/README.md index 2f7291fd..3763d9d0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 - `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 -- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、自定义角色模板、班级/教师/学生范围权限、审计日志。 +- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册 JSON 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。 @@ -178,4 +178,4 @@ npm run check:refactor 2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。 3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。 4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。 -5. 学生批量导入/批量分班、微信网页/QQ 登录、退款对账、CRM worker、公共题库授权和租户采纳。 +5. 微信网页/QQ 登录、退款对账、CRM worker、公共题库授权、租户采纳、题目反馈、签到积分和排行榜。 diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 887be245..1ef19ca3 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -14,7 +14,7 @@ const ROLE_PERMISSION_DEFAULTS: Record = { tenant_owner: ['*'], tenant_admin: ['*'], tenant_operator: ['content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'crm:read'], - teacher: ['content:*', 'classes:read', 'students:read'], + teacher: ['content:*', 'classes:read', 'students:read', 'students:notes:*', 'students:followups:*'], sales: ['codes:*', 'coupons:*', 'referral:*'], agent: ['codes:read', 'coupons:read', 'referral:self'], student: [], @@ -106,6 +106,12 @@ export function tenantPermissionCatalog() { { key: 'classes:write', label: '班级管理' }, { key: 'students:read', label: '学生查看' }, { key: 'students:write', label: '学生管理' }, + { key: 'students:bulk:write', label: '学生批量导入/分班' }, + { key: 'students:status:write', label: '学生禁用/恢复' }, + { key: 'students:notes:read', label: '学生备注查看' }, + { key: 'students:notes:write', label: '学生备注管理' }, + { key: 'students:followups:read', label: '学生跟进任务查看' }, + { key: 'students:followups:write', label: '学生跟进任务管理' }, { key: 'members:read', label: '成员查看' }, { key: 'members:write', label: '成员管理' }, { key: 'roles:read', label: '角色模板查看' }, diff --git a/apps/api/src/features/tenant-admin/classes.ts b/apps/api/src/features/tenant-admin/classes.ts index abef0b7b..aa9f2b72 100644 --- a/apps/api/src/features/tenant-admin/classes.ts +++ b/apps/api/src/features/tenant-admin/classes.ts @@ -15,6 +15,13 @@ const CLASS_STATUSES = ['active', 'disabled', 'archived']; const CLASS_MEMBER_TYPES = ['student', 'teacher', 'assistant', 'head_teacher']; const CLASS_MEMBER_STATUSES = ['active', 'disabled', 'removed']; const STUDENT_MEMBER_STATUSES = ['active', 'invited', 'disabled']; +const STUDENT_NOTE_TYPES = ['general', 'learning', 'service', 'sales', 'risk', 'follow_up']; +const STUDENT_NOTE_VISIBILITIES = ['tenant_staff', 'class_staff', 'author_only']; +const STUDENT_FOLLOWUP_TYPES = ['learning', 'service', 'sales', 'renewal', 'risk', 'custom']; +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; function nullableString(value: unknown) { return typeof value === 'string' && value.trim() ? value.trim() : null; @@ -33,6 +40,10 @@ function intValue(value: unknown, fallback: number) { return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback; } +function boolValue(value: unknown, fallback: boolean) { + return typeof value === 'boolean' ? value : fallback; +} + function optionalChoice(value: unknown, allowed: string[], fallback: string) { const candidate = nullableString(value) || fallback; if (!allowed.includes(candidate)) { @@ -57,6 +68,21 @@ function classCodeValue(value: unknown) { return code; } +function asObjectArray(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'); + } + return value.map((item, index) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + throw new HttpError(400, `${fieldName}[${index}] must be an object`, 'INVALID_ARRAY_ITEM'); + } + return item as JsonBody; + }); +} + function canReadAllClassScope(auth: TenantAdminAuth) { return ( auth.role === 'tenant_owner' || @@ -76,6 +102,7 @@ function maskStudentFields>(auth: TenantAdminA return { ...item, phone: null, + studentPhone: null, }; } @@ -128,6 +155,62 @@ async function ensureReadableClass(auth: TenantAdminAuth, classId: string) { if (!rows[0]) throw new HttpError(404, 'Class not found', 'CLASS_NOT_FOUND'); } +async function ensureStudentInScope(auth: TenantAdminAuth, studentUserId: string) { + const scopedIds = await scopedClassIds(auth); + const params: unknown[] = [auth.tenantId, studentUserId]; + const filters = [ + 'tm.tenant_id = $1', + 'tm.user_id = $2::uuid', + `tm.role = 'student'`, + ]; + if (scopedIds) { + params.push(scopedIds); + filters.push(`exists ( + select 1 from public.tenant_class_members scoped_cm + where scoped_cm.tenant_id = tm.tenant_id + and scoped_cm.user_id = tm.user_id + and scoped_cm.class_id = any($${params.length}::uuid[]) + and scoped_cm.member_type = 'student' + and scoped_cm.status = 'active' + )`); + } + const rows = await query<{ userId: string }>( + ` + select tm.user_id as "userId" + from public.tenant_memberships tm + where ${filters.join(' and ')} + limit 1 + `, + params, + ); + if (!rows[0]) { + throw new HttpError(scopedIds ? 403 : 404, 'Student is outside the current data scope', 'STUDENT_SCOPE_REQUIRED'); + } +} + +async function ensureUserTenantMembership( + client: pg.PoolClient, + tenantId: string, + userId: string | null, + allowedRoles: string[], + errorCode: string, +) { + if (!userId) return; + const result = await client.query<{ id: string }>( + ` + select id + from public.tenant_memberships + where tenant_id = $1 + and user_id = $2 + and role = any($3::text[]) + and status = 'active' + limit 1 + `, + [tenantId, userId, allowedRoles], + ); + if (!result.rows[0]) throw new HttpError(400, 'User is not an active tenant member', errorCode); +} + async function ensureTenantReference( client: pg.PoolClient, tableName: 'regions' | 'schools' | 'majors', @@ -683,6 +766,181 @@ export async function upsertTenantStudentRoute(ctx: RequestContext) { return { item }; } +export async function updateTenantStudentStatusRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'students:status:write'); + const body = await readJsonBody(ctx); + const userId = requiredString(body, 'userId'); + const status = optionalChoice(body.status, STUDENT_MEMBER_STATUSES, 'active'); + + const item = await transaction(async client => { + const result = await client.query( + ` + update public.tenant_memberships + set status = $3, + updated_at = now() + where tenant_id = $1 + and user_id = $2 + and role = 'student' + returning id as "membershipId", user_id as "userId", role, status, updated_at as "updatedAt" + `, + [auth.tenantId, userId, status], + ); + if (!result.rows[0]) throw new HttpError(404, 'Student membership not found', 'STUDENT_NOT_FOUND'); + await recordAudit(client, auth, 'tenant.student.status_updated', 'tenant_memberships', result.rows[0].membershipId, { + userId, + status, + reason: nullableString(body.reason), + }); + return result.rows[0]; + }); + + return { item }; +} + +export async function bulkUpsertTenantStudentsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'students:bulk:write'); + const body = await readJsonBody(ctx); + const students = asObjectArray(body.students, 'students', MAX_BULK_STUDENTS); + + const result = await transaction(async client => { + const items: unknown[] = []; + const errors: unknown[] = []; + + for (let index = 0; index < students.length; index += 1) { + const student = students[index]; + try { + const userId = await resolveOrCreateUser(client, student, 'student'); + const status = optionalChoice(student.status, STUDENT_MEMBER_STATUSES, 'active'); + const regionId = nullableString(student.regionId); + const selectedSchoolId = nullableString(student.selectedSchoolId); + const selectedMajorId = nullableString(student.selectedMajorId); + await ensureTenantReference(client, 'regions', auth.tenantId, regionId, 'REGION_NOT_FOUND'); + await ensureTenantReference(client, 'schools', auth.tenantId, selectedSchoolId, 'SCHOOL_NOT_FOUND'); + await ensureTenantReference(client, 'majors', auth.tenantId, selectedMajorId, 'MAJOR_NOT_FOUND'); + await ensureTenantMembership(client, auth.tenantId, userId, 'student', status); + + const profile = await client.query( + ` + insert into public.student_profiles ( + tenant_id, user_id, legacy_user_id, region_id, selected_school_id, + selected_major_id, stats, progress, module_selections + ) + values ($1, $2, $3, $4::uuid, $5::uuid, $6::uuid, $7::jsonb, $8::jsonb, $9::jsonb) + on conflict (tenant_id, user_id) + do update set legacy_user_id = coalesce(excluded.legacy_user_id, public.student_profiles.legacy_user_id), + region_id = coalesce(excluded.region_id, public.student_profiles.region_id), + selected_school_id = coalesce(excluded.selected_school_id, public.student_profiles.selected_school_id), + selected_major_id = coalesce(excluded.selected_major_id, public.student_profiles.selected_major_id), + stats = case when $7::jsonb = '{}'::jsonb then public.student_profiles.stats else excluded.stats end, + progress = case when $8::jsonb = '{}'::jsonb then public.student_profiles.progress else excluded.progress end, + module_selections = case when $9::jsonb = '{}'::jsonb then public.student_profiles.module_selections else excluded.module_selections end, + updated_at = now() + returning id as "profileId", user_id as "userId", region_id as "regionId", + selected_school_id as "selectedSchoolId", selected_major_id as "selectedMajorId" + `, + [ + auth.tenantId, + userId, + nullableString(student.legacyUserId), + regionId, + selectedSchoolId, + selectedMajorId, + jsonBodyValue(student.stats), + jsonBodyValue(student.progress), + jsonBodyValue(student.moduleSelections), + ], + ); + items.push({ index, ...profile.rows[0], status }); + } catch (error) { + errors.push({ + index, + code: error instanceof HttpError ? error.code : 'STUDENT_BULK_ITEM_FAILED', + message: error instanceof Error ? error.message : 'Student import item failed', + }); + } + } + + await recordAudit(client, auth, 'tenant.students.bulk_upserted', 'student_profiles', null, { + total: students.length, + successCount: items.length, + errorCount: errors.length, + }); + return { total: students.length, successCount: items.length, errorCount: errors.length, items, errors }; + }); + + return result; +} + +export async function bulkAssignTenantClassMembersRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'students:bulk:write'); + const body = await readJsonBody(ctx); + const classId = requiredString(body, 'classId'); + const assignments = asObjectArray(body.assignments || body.students, 'assignments', MAX_BULK_CLASS_ASSIGNMENTS); + + const result = await transaction(async client => { + await ensureTenantClass(client, auth.tenantId, classId); + const items: unknown[] = []; + const errors: unknown[] = []; + + for (let index = 0; index < assignments.length; index += 1) { + const assignment = assignments[index]; + try { + const memberType = optionalChoice(assignment.memberType, CLASS_MEMBER_TYPES, 'student'); + const status = optionalChoice(assignment.status, CLASS_MEMBER_STATUSES, 'active'); + const userId = await resolveOrCreateUser(client, assignment, membershipRoleForClassMember(memberType)); + await ensureTenantMembership(client, auth.tenantId, userId, membershipRoleForClassMember(memberType), 'active'); + if (memberType === 'student') { + await client.query( + ` + insert into public.student_profiles (tenant_id, user_id, stats, progress) + values ($1, $2, '{}'::jsonb, '{}'::jsonb) + on conflict (tenant_id, user_id) do nothing + `, + [auth.tenantId, userId], + ); + } + const item = await client.query( + ` + insert into public.tenant_class_members ( + tenant_id, class_id, user_id, member_type, status, left_at, + metadata, created_by, updated_by + ) + values ($1, $2, $3, $4, $5, case when $5 in ('disabled', 'removed') then now() else null end, $6::jsonb, $7, $7) + on conflict (tenant_id, class_id, user_id, member_type) + do update set status = excluded.status, + left_at = excluded.left_at, + metadata = excluded.metadata, + updated_by = excluded.updated_by, + updated_at = now() + returning id, class_id as "classId", user_id as "userId", member_type as "memberType", status + `, + [auth.tenantId, classId, userId, memberType, status, jsonBodyValue(assignment.metadata), auth.userId], + ); + items.push({ index, ...item.rows[0] }); + } catch (error) { + errors.push({ + index, + code: error instanceof HttpError ? error.code : 'CLASS_BULK_ASSIGN_ITEM_FAILED', + message: error instanceof Error ? error.message : 'Class assignment item failed', + }); + } + } + + await recordAudit(client, auth, 'tenant.class_members.bulk_assigned', 'tenant_class_members', classId, { + classId, + total: assignments.length, + successCount: items.length, + errorCount: errors.length, + }); + return { total: assignments.length, successCount: items.length, errorCount: errors.length, items, errors }; + }); + + return result; +} + export async function tenantTeachersRoute(ctx: RequestContext) { const auth = await requireTenantAdmin(ctx); requireTenantPermission(auth, 'classes:read'); @@ -722,3 +980,262 @@ export async function tenantTeachersRoute(ctx: RequestContext) { return { items }; } + +export async function tenantStudentNotesRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'students:notes:read'); + const studentUserId = stringParam(ctx, 'studentUserId'); + if (!studentUserId) throw new HttpError(400, 'studentUserId is required', 'STUDENT_USER_ID_REQUIRED'); + await ensureStudentInScope(auth, studentUserId); + const limit = intParam(ctx, 'limit', 100, 300); + + const items = await query>( + ` + select sn.id, sn.student_user_id as "studentUserId", sn.note_type as "noteType", + sn.content, sn.visibility, sn.is_pinned as "isPinned", sn.metadata, + sn.created_by as "createdBy", sn.updated_by as "updatedBy", + sn.created_at as "createdAt", sn.updated_at as "updatedAt", + creator.name as "createdByName", creator.username as "createdByUsername" + from public.tenant_student_notes sn + left join public.platform_users creator on creator.id = sn.created_by + where sn.tenant_id = $1 + and sn.student_user_id = $2 + and ( + sn.visibility <> 'author_only' + or sn.created_by = $3 + or $4::boolean + ) + order by sn.is_pinned desc, sn.created_at desc + limit $5 + `, + [auth.tenantId, studentUserId, auth.userId, canReadAllClassScope(auth), limit], + ); + return { items }; +} + +export async function upsertTenantStudentNoteRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'students:notes:write'); + const body = await readJsonBody(ctx); + const studentUserId = requiredString(body, 'studentUserId'); + await ensureStudentInScope(auth, studentUserId); + + const item = await transaction(async client => { + const result = await client.query( + ` + insert into public.tenant_student_notes ( + id, tenant_id, student_user_id, note_type, content, visibility, + is_pinned, metadata, created_by, updated_by + ) + values ( + coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5, $6, + $7, $8::jsonb, $9, $9 + ) + on conflict (id) + do update set note_type = excluded.note_type, + content = excluded.content, + visibility = excluded.visibility, + is_pinned = excluded.is_pinned, + metadata = excluded.metadata, + updated_by = excluded.updated_by, + updated_at = now() + where public.tenant_student_notes.tenant_id = excluded.tenant_id + and public.tenant_student_notes.student_user_id = excluded.student_user_id + and ( + public.tenant_student_notes.created_by = $9 + or $10::boolean + ) + returning id, student_user_id as "studentUserId", note_type as "noteType", + content, visibility, is_pinned as "isPinned", metadata, + created_by as "createdBy", updated_by as "updatedBy", + created_at as "createdAt", updated_at as "updatedAt" + `, + [ + auth.tenantId, + nullableString(body.id), + studentUserId, + optionalChoice(body.noteType, STUDENT_NOTE_TYPES, 'general'), + requiredString(body, 'content'), + optionalChoice(body.visibility, STUDENT_NOTE_VISIBILITIES, 'tenant_staff'), + boolValue(body.isPinned, false), + jsonBodyValue(body.metadata), + auth.userId, + canReadAllClassScope(auth), + ], + ); + if (!result.rows[0]) throw new HttpError(404, 'Student note not found or not editable', 'STUDENT_NOTE_NOT_EDITABLE'); + await recordAudit(client, auth, 'tenant.student_note.upserted', 'tenant_student_notes', result.rows[0].id, { + studentUserId, + noteType: result.rows[0].noteType, + }); + return result.rows[0]; + }); + + return { item }; +} + +export async function tenantStudentFollowupsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'students:followups:read'); + const limit = intParam(ctx, 'limit', 100, 300); + const studentUserId = stringParam(ctx, 'studentUserId'); + const status = stringParam(ctx, 'status'); + const assignedTo = stringParam(ctx, 'assignedToUserId'); + const scopedIds = await scopedClassIds(auth); + if (studentUserId) await ensureStudentInScope(auth, studentUserId); + if (status && !STUDENT_FOLLOWUP_STATUSES.includes(status)) { + throw new HttpError(400, `Invalid follow-up status: ${status}`, 'INVALID_FOLLOWUP_STATUS'); + } + + const params: unknown[] = [auth.tenantId]; + const filters = ['sf.tenant_id = $1']; + if (studentUserId) { + params.push(studentUserId); + filters.push(`sf.student_user_id = $${params.length}::uuid`); + } + if (status) { + params.push(status); + filters.push(`sf.status = $${params.length}`); + } + if (assignedTo) { + params.push(assignedTo); + filters.push(`sf.assigned_to_user_id = $${params.length}::uuid`); + } + if (scopedIds) { + params.push(scopedIds); + filters.push(`exists ( + select 1 from public.tenant_class_members scoped_cm + where scoped_cm.tenant_id = sf.tenant_id + and scoped_cm.user_id = sf.student_user_id + and scoped_cm.class_id = any($${params.length}::uuid[]) + and scoped_cm.member_type = 'student' + and scoped_cm.status = 'active' + )`); + } + params.push(limit); + + const items = await query>( + ` + select sf.id, sf.student_user_id as "studentUserId", + student.name as "studentName", student.phone as "studentPhone", + sf.assigned_to_user_id as "assignedToUserId", assignee.name as "assignedToName", + sf.class_id as "classId", tc.name as "className", + sf.title, sf.description, sf.followup_type as "followupType", + sf.priority, sf.status, sf.due_at as "dueAt", + sf.completed_at as "completedAt", sf.completed_by as "completedBy", + sf.metadata, sf.created_by as "createdBy", sf.updated_by as "updatedBy", + sf.created_at as "createdAt", sf.updated_at as "updatedAt" + from public.tenant_student_followups sf + left join public.platform_users student on student.id = sf.student_user_id + left join public.platform_users assignee on assignee.id = sf.assigned_to_user_id + left join public.tenant_classes tc on tc.tenant_id = sf.tenant_id and tc.id = sf.class_id + where ${filters.join(' and ')} + order by case sf.priority + when 'urgent' then 1 + when 'high' then 2 + when 'normal' then 3 + else 4 + end, sf.due_at asc nulls last, sf.created_at desc + limit $${params.length} + `, + params, + ); + + return { items: items.map(item => maskStudentFields(auth, item)) }; +} + +export async function upsertTenantStudentFollowupRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'students:followups:write'); + const body = await readJsonBody(ctx); + const studentUserId = requiredString(body, 'studentUserId'); + await ensureStudentInScope(auth, studentUserId); + const requestedClassId = nullableString(body.classId); + if (requestedClassId) await ensureReadableClass(auth, requestedClassId); + + const item = await transaction(async client => { + const classId = requestedClassId; + const assignedToUserId = nullableString(body.assignedToUserId); + if (classId) await ensureTenantClass(client, auth.tenantId, classId); + await ensureUserTenantMembership( + client, + auth.tenantId, + assignedToUserId, + ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'], + 'ASSIGNEE_NOT_FOUND', + ); + const status = optionalChoice(body.status, STUDENT_FOLLOWUP_STATUSES, 'open'); + const completedAt = status === 'done' ? nullableString(body.completedAt) || new Date().toISOString() : null; + const completedBy = status === 'done' ? auth.userId : null; + + const result = await client.query( + ` + insert into public.tenant_student_followups ( + id, tenant_id, student_user_id, assigned_to_user_id, class_id, + title, description, followup_type, priority, status, due_at, + completed_at, completed_by, metadata, created_by, updated_by + ) + values ( + coalesce($2::uuid, gen_random_uuid()), $1, $3, $4::uuid, $5::uuid, + $6, $7, $8, $9, $10, $11::timestamptz, + $12::timestamptz, $13::uuid, $14::jsonb, $15, $15 + ) + on conflict (id) + do update set assigned_to_user_id = excluded.assigned_to_user_id, + class_id = excluded.class_id, + title = excluded.title, + description = excluded.description, + followup_type = excluded.followup_type, + priority = excluded.priority, + status = excluded.status, + due_at = excluded.due_at, + completed_at = case + when excluded.status = 'done' then coalesce(excluded.completed_at, public.tenant_student_followups.completed_at, now()) + else null + end, + completed_by = case + when excluded.status = 'done' then coalesce(excluded.completed_by, public.tenant_student_followups.completed_by) + else null + end, + metadata = excluded.metadata, + updated_by = excluded.updated_by, + updated_at = now() + where public.tenant_student_followups.tenant_id = excluded.tenant_id + and public.tenant_student_followups.student_user_id = excluded.student_user_id + returning id, student_user_id as "studentUserId", + assigned_to_user_id as "assignedToUserId", class_id as "classId", + title, description, followup_type as "followupType", priority, status, + due_at as "dueAt", completed_at as "completedAt", completed_by as "completedBy", + metadata, created_by as "createdBy", updated_by as "updatedBy", + created_at as "createdAt", updated_at as "updatedAt" + `, + [ + auth.tenantId, + nullableString(body.id), + studentUserId, + assignedToUserId, + classId, + requiredString(body, 'title'), + nullableString(body.description), + optionalChoice(body.followupType, STUDENT_FOLLOWUP_TYPES, 'learning'), + optionalChoice(body.priority, STUDENT_FOLLOWUP_PRIORITIES, 'normal'), + status, + nullableString(body.dueAt), + completedAt, + completedBy, + jsonBodyValue(body.metadata), + auth.userId, + ], + ); + if (!result.rows[0]) throw new HttpError(404, 'Student follow-up not found', 'STUDENT_FOLLOWUP_NOT_FOUND'); + await recordAudit(client, auth, 'tenant.student_followup.upserted', 'tenant_student_followups', result.rows[0].id, { + studentUserId, + status, + assignedToUserId, + classId, + }); + return result.rows[0]; + }); + + return { item }; +} diff --git a/apps/api/src/features/tenant-admin/index.ts b/apps/api/src/features/tenant-admin/index.ts index 9935e85c..3983458c 100644 --- a/apps/api/src/features/tenant-admin/index.ts +++ b/apps/api/src/features/tenant-admin/index.ts @@ -1,11 +1,18 @@ import type { RouteDefinition } from '../../core/router.js'; import { disableTenantClassRoute, + bulkAssignTenantClassMembersRoute, + bulkUpsertTenantStudentsRoute, removeTenantClassMemberRoute, tenantClassesRoute, tenantClassMembersRoute, + tenantStudentFollowupsRoute, + tenantStudentNotesRoute, tenantStudentsRoute, tenantTeachersRoute, + updateTenantStudentStatusRoute, + upsertTenantStudentFollowupRoute, + upsertTenantStudentNoteRoute, upsertTenantClassMemberRoute, upsertTenantClassRoute, upsertTenantStudentRoute, @@ -56,8 +63,15 @@ export const tenantAdminRoutes: RouteDefinition[] = [ ['GET', '/api/tenant-admin/classes/members', tenantClassMembersRoute], ['PUT', '/api/tenant-admin/classes/members', upsertTenantClassMemberRoute], ['POST', '/api/tenant-admin/classes/members/remove', removeTenantClassMemberRoute], + ['POST', '/api/tenant-admin/classes/members/bulk-assign', bulkAssignTenantClassMembersRoute], ['GET', '/api/tenant-admin/students', tenantStudentsRoute], ['PUT', '/api/tenant-admin/students', upsertTenantStudentRoute], + ['POST', '/api/tenant-admin/students/bulk-upsert', bulkUpsertTenantStudentsRoute], + ['POST', '/api/tenant-admin/students/status', updateTenantStudentStatusRoute], + ['GET', '/api/tenant-admin/students/notes', tenantStudentNotesRoute], + ['PUT', '/api/tenant-admin/students/notes', upsertTenantStudentNoteRoute], + ['GET', '/api/tenant-admin/students/followups', tenantStudentFollowupsRoute], + ['PUT', '/api/tenant-admin/students/followups', upsertTenantStudentFollowupRoute], ['GET', '/api/tenant-admin/teachers', tenantTeachersRoute], ['GET', '/api/tenant-admin/overview', tenantOverviewRoute], ['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute], diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 517a0044..d0e47c95 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -44,6 +44,7 @@ | 租户角色权限 | 可联调 | `tenant_memberships.role + permissions + role_template_id`,接口有权限点校验 | | 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`,支持权限、菜单、模块、字段、数据范围配置;前端 UI 继续补 | | 班级/教师/学生范围权限 | 可联调 | `tenant_classes`、`tenant_class_members` + `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`;教师默认只看自己负责班级,字段权限可脱敏学生手机号 | +| 学生运营备注和跟进 | 可联调 | `tenant_student_notes`、`tenant_student_followups` + `/api/tenant-admin/students/notes`、`students/followups`;教师/班主任只能操作范围内学生,支持备注可见性、任务指派、完成状态和审计 | ## 学生端题库主链路 @@ -116,6 +117,7 @@ | 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches`、`activation-codes` | | 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members`、`permissions`、`role-templates`、`audit-logs` | | 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 | +| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 | | 平台租户/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*` | | 数据看板聚合接口 | 待补齐 | 表基础已有,缺完整 dashboard API | diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 8982e2a1..1b93f663 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -20,6 +20,7 @@ - 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。 - 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。 - 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 +- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。 - `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 - 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。 @@ -145,8 +146,15 @@ POST /api/tenant-admin/classes/disable GET /api/tenant-admin/classes/members PUT /api/tenant-admin/classes/members POST /api/tenant-admin/classes/members/remove +POST /api/tenant-admin/classes/members/bulk-assign GET /api/tenant-admin/students PUT /api/tenant-admin/students +POST /api/tenant-admin/students/bulk-upsert +POST /api/tenant-admin/students/status +GET /api/tenant-admin/students/notes +PUT /api/tenant-admin/students/notes +GET /api/tenant-admin/students/followups +PUT /api/tenant-admin/students/followups GET /api/tenant-admin/teachers GET /api/tenant-admin/overview PUT /api/tenant-admin/branding @@ -192,6 +200,7 @@ GET /api/tenant-admin/audit-logs - `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。 - 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。 - 班级学生 API 会按 `tenant_memberships.role_template_id -> tenant_role_templates.data_scope`、成员显式权限和 `tenant_class_members` 共同确定可见范围;非全局权限教师只能查看自己负责班级的学生。 +- 学生批量导入、批量分班、学生状态、备注和跟进任务都使用独立权限点;教师默认可为范围内学生写备注和跟进任务,但不能批量导入、禁用学生或放大可见班级。 - 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。 - CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。 - 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider。真实对象存在性校验、PDF 预览渲染、防盗链、水印和大文件上传后 worker 校验仍需继续补。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 99f98cda..13c19a02 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -1,6 +1,6 @@ # 旧题库功能差距矩阵 -更新时间:2026-06-28 +更新时间:2026-06-29 旧项目位于 `F:\project\参考\旧题库项目`,只作为功能、样式、交互和迁移参考。新项目不再复刻 PocketBase/SQLite 的数据结构,而是以新多租户 SaaS 模型为准。 @@ -8,6 +8,7 @@ - `已覆盖`:新后端已有对应模型和接口。 - `部分覆盖`:已有主干,但商用体验或边界还要补。 +- `本地待推`:本地代码已实现并通过过验证,但尚未提交/推送到 Gitea。 - `未覆盖`:需要新增后端能力。 - `前端为主`:后端已有基础,主要由 Taro/H5 实现展示和交互。 @@ -15,32 +16,35 @@ | 旧功能/页面 | 旧项目参考 | 新后端状态 | 待补齐 | | --- | --- | --- | --- | -| 登录/注册 | `pages/Login.tsx` | 部分覆盖 | 短信 mock/session 已有;微信小程序、微信网页、QQ、正式 JWT 未完成 | +| 登录/注册 | `pages/Login.tsx` | 部分覆盖 | 短信、Supabase JWT、微信小程序登录主链路已有;微信网页登录、QQ OAuth、手机号换绑/补绑和生产账号联调待补 | | 选地区 | `pages/RegionSelector.tsx` | 已覆盖 | 需要前端按租户套餐和权益展示可选地区 | -| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、入口、个人统计有基础;缺完整运营动态/学习任务聚合 | +| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、FAQ、时间线、入口、个人统计有基础;缺考试倒计时 API、完整运营动态和学习任务聚合 | | 题库入口 | `pages/SubjectSelector.tsx`、`RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` | | 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 | -| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验、session 快照、练习历史和趋势统计已由后端强制;继续补断点续练、更多题型渲染 | +| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验、session 快照、练习历史和趋势统计已由后端强制;继续补断点续练和题目反馈 | | 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照、访问控制和历史统计,前端需按 mode 调用 | -| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、交卷评分、分段统计和错题解析汇总已覆盖;后续补排名、断点续练、复盘体验 | +| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、交卷评分、分段统计和错题解析汇总已覆盖;后续补排行榜/排名、断点续练、复盘体验 | | 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 错题列表、移出错题、复习计划和 `wrong_review` 后端组卷已覆盖;后续补更细的间隔复习算法 | | 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 | | 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 | -| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音策略和更精细的间隔算法参数 | +| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 | | 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 | | 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 | -| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐/订单/权益/激活码已有;缺真实支付、优惠券抵扣 | -| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计和趋势已有;缺完整勋章、签到、学习报告可视化 | -| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账/签名下载已有;缺 PDF 预览、下载水印、防盗链 | +| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐/订单/权益/激活码、微信支付/支付宝 provider 主链路已有;缺优惠券下单抵扣、订单状态轮询、激活码预检查、退款/对账/补偿任务 | +| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计和趋势已有;缺勋章 API、签到积分、考试倒计时、账号绑定/换绑、学习报告可视化 | +| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账/签名下载已有;缺 PDF 预览、水印、防盗链和上传后对象校验 | | AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 | +| 题目反馈 | `02-API接口.md` 用户反馈 | 未覆盖 | 需新增题目反馈表、提交接口、租户后台处理流和通知 | +| 签到积分 | `Profile.tsx`、`02-API接口.md` | 未覆盖 | 旧用户表有 `lastCheckInDate/score`;新 schema 有字段基础,但缺签到 API、积分流水和活动规则 | +| 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 未覆盖 | 需新增题库/模考/背单词排行榜聚合接口和防刷策略 | ## 租户后台功能 | 旧功能/组件 | 新后端状态 | 待补齐 | | --- | --- | --- | -| 用户管理 | 部分覆盖 | 租户成员 API 已有;学生用户列表、批量导入、禁用/补绑/CRM 批量推送还需完善 | +| 用户管理 | 本地待推 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务已在本地实现;批量 CRM 推送、补绑、学习督导自动化待补 | | 销售/代理管理 | 部分覆盖 | referral/team/stats 有;缺分佣比例、结算单、审核、导出 | -| 班级/教师管理 | 未覆盖 | 需新增班级、学生分班、教师可见范围 | +| 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 | | 数据看板 | 部分覆盖 | 表基础有;缺收益、注册、答题、活跃、套餐销量等聚合 API | | 地区管理 | 部分覆盖 | 地区和内容入口已有;缺按 SaaS 套餐限制地区/题库授权的完整流程 | | 品牌配置 | 已覆盖 | 需要前端做预览和主题发布体验 | @@ -54,13 +58,13 @@ | 激活码 | 已覆盖 | 批次、生成、兑换主链路已有 | | 勋章管理 | 部分覆盖 | 表结构有 badges/user_badges;缺后台和学生端 API | | 题库录入 | 已覆盖 | 单题创建/更新、JSON 导入、集合/蓝图已有 | -| 题库导出 PDF/Word/JSON | 未覆盖 | 旧前端有导出组件;新后端需决定是否服务端导出或前端导出 | +| 题库导出 PDF/Word/JSON | 未覆盖 | 旧前端有导出组件;新后端需决定服务端导出、导出水印和权限审计 | | 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 | | 背单词维护 | 已覆盖 | 单元/单词 CRUD 和导入已有 | | 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 | | 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 已有 | | 视频维护/绑定 | 已覆盖 | video CRUD 和 question-video 绑定已有 | -| CRM 配置和队列 | 部分覆盖 | 配置/队列已有;发送 worker 待补 | +| CRM 配置和队列 | 部分覆盖 | 配置/队列已有;钉钉/飞书/企微真实发送 worker、签名、重试、死信待补 | | 对象存储配置 | 部分覆盖 | 系统 env provider 已有;租户级存储策略、上传后校验待补 | ## 平台 SaaS 后台功能 @@ -71,7 +75,7 @@ | SaaS 套餐 | 已覆盖 | 需要和地区/题库授权策略打通 | | 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 | | 租户用量记录 | 已覆盖 | 自动采集 worker 待补 | -| 公共题库/地区题库 | 部分覆盖 | question_banks 有 source_scope;缺租户采纳、授权、版本同步 | +| 公共题库/地区题库 | 部分覆盖 | question_banks 有 source_scope;缺平台披露策略、SaaS 套餐授权、租户采纳/复制/版本同步 | | 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 | | 租户安全审计 | 部分覆盖 | audit logs 有;缺平台级审计报表 | @@ -86,6 +90,21 @@ ## 后端补齐优先级 +## 和旧题库相比仍缺的明确功能 + +这些是旧项目中已经出现过、但新后端还没有完整业务闭环的功能: + +1. 题目反馈:学生提交题目纠错、租户后台处理、状态流转、处理通知。 +2. 签到积分:每日签到、积分流水、连续签到、积分和活动/兑换的关系。 +3. 排行榜:刷题、模考、背单词排行榜,以及防刷、租户/地区/班级维度。 +4. 考试倒计时:`exam_dates` 表已有,但还缺学生端查询和后台维护 API。 +5. 订单状态轮询和激活码预检查:订单列表和兑换已有,但旧商城体验需要更细的状态查询/预检接口。 +6. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。 +7. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。 +8. 导入扩展:Excel/CSV、分数线、视频批量导入和大批量异步 worker。 +9. 公共题库商业化:平台公共/地区题库披露、租户采纳、套餐授权、版本同步。 +10. CRM/销售结算:真实 CRM worker、轮询/定向分配、分佣规则、结算单、审核和导出。 + ### P0:前端联调到云端前 1. 正式鉴权:API 已支持 Supabase Auth JWT;继续补真实云端 Auth/JWKS 回归、RLS 深测和自定义角色权限细化。 diff --git a/docs/refactor/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index 2bd47256..d6b44a49 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -1,6 +1,6 @@ # 多租户与鉴权安全契约 -更新时间:2026-06-28 +更新时间:2026-06-29 这个系统后续要卖给同行作为题库 SaaS,因此租户隔离、鉴权、资源权限和审计是商用红线。前端可以先按迁移期接口联调,也可以按 Supabase 官方推荐使用 publishable key + RLS 的客户端能力管理 Auth/session,但正式上云验收前必须完成本文件的 P0 项。 @@ -159,6 +159,9 @@ provider event id 幂等 - `tenant_classes` 和 `tenant_class_members` 提供班级、班主任、教师、助教、学生分组边界。 - 教师如无 `classes:write`、`students:write`、`members:read` 等全局管理权限,只能查看自己在 `tenant_class_members` 中负责的班级及这些班级下的学生;也可由角色模板 `dataScope.classIds` 显式限定。 - 学生手机号等敏感字段可由 `fieldPermissions` 控制,后端会对不可见字段返回 `null`,前端不得绕过其它接口补取。 +- 学生批量导入、学生禁用/恢复、批量分班、备注、跟进任务分别使用 `students:bulk:write`、`students:status:write`、`students:notes:*`、`students:followups:*` 权限点。 +- 教师默认只可对范围内学生创建备注和跟进任务;批量导入、禁用/恢复学生、跨班级指派跟进任务必须显式授权并通过后端范围校验。 +- 备注支持 `tenant_staff`、`class_staff`、`author_only` 可见性;`author_only` 备注只能由作者或全局学生管理权限账号更新。 后续要补: diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 9059caf1..908394d1 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -9,7 +9,7 @@ - Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。 - Node.js API 分层:`core/features`。 - 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心。 -- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理。 +- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。 - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 @@ -18,6 +18,7 @@ - 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。 - 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。 - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 +- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;后续补批量 CRM 推送和自动学习督导。 - 本地验证:`npm run check:refactor` 已通过。 当前更适合进入前端联调前阅读的总览文档: @@ -90,8 +91,8 @@ - 销售/代理转化、分佣结算、客资跟进效果。 8. 学生运营管理 - - 已完成学生列表、学生资料维护、班级分组和教师范围可见。 - - 继续补学生批量导入、禁用/恢复、批量分班、批量 CRM 推送、学习督导任务和家校/班主任备注。 + - 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。 + - 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和前端 UI。 9. AI 择校推荐 - 地区考试数据上下文。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index bea86108..7bfe7af4 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -172,8 +172,10 @@ tenant::theme | 个人中心 | `GET/PATCH /api/profile/me` | | 销售分享 | `/api/referral/resolve`、`track-event`、`bind` | | 租户班级 | `GET/PUT /api/tenant-admin/classes`、`POST /api/tenant-admin/classes/disable` | -| 班级成员 | `GET/PUT /api/tenant-admin/classes/members`、`POST /api/tenant-admin/classes/members/remove` | -| 租户学生 | `GET/PUT /api/tenant-admin/students` | +| 班级成员 | `GET/PUT /api/tenant-admin/classes/members`、`POST /api/tenant-admin/classes/members/remove`、`POST /api/tenant-admin/classes/members/bulk-assign` | +| 租户学生 | `GET/PUT /api/tenant-admin/students`、`POST /api/tenant-admin/students/bulk-upsert`、`POST /api/tenant-admin/students/status` | +| 学生备注 | `GET/PUT /api/tenant-admin/students/notes` | +| 学生跟进任务 | `GET/PUT /api/tenant-admin/students/followups` | | 租户教师 | `GET /api/tenant-admin/teachers` | ## 练习访问控制契约 @@ -444,6 +446,8 @@ content_entries - 管理后台菜单按 `GET /api/tenant-admin/permissions` 返回的 `current.permissions`、`current.templatePermissions`、`current.menuPermissions`、`current.modulePermissions` 渲染;接口权限仍以后端校验为准。 - 教师、班主任、助教类账号进入租户后台时,学生列表以 `GET /api/tenant-admin/students` 返回的 `scoped` 和 `items` 为准;前端不要自行用本地班级 ID 放大查询范围。 - 学生手机号、订单金额、客资归属等敏感字段按 `fieldPermissions` 控制显示;字段被后端返回为 `null` 时前端展示脱敏占位,不要从其它接口补取。 +- 学生批量导入和批量分班接口会返回 `total/successCount/errorCount/results`,前端必须展示逐行错误,不要在浏览器端静默丢弃失败行。 +- 教师可以为范围内学生创建备注和跟进任务,但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定;前端只按返回值渲染。 - H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。 ## 登录对接 @@ -608,7 +612,7 @@ GET /api/commerce/entitlements - 品牌/主题/域名/公开设置 - 支付账户/登录 provider/密钥引用 - 用户与成员权限 -- 班级/教师/学生:`/api/tenant-admin/classes`、`classes/members`、`students`、`teachers` +- 班级/教师/学生:`/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`、`students/notes`、`students/followups` - 角色模板:`GET/PUT /api/tenant-admin/role-templates`、`POST /api/tenant-admin/role-templates/disable` - 内容入口/分类树/题目集合/练习蓝图 - 题目/单词/知识手册/分数线/视频维护 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 219ec0df..e0b50f0d 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -2762,6 +2762,222 @@ async function testTenantClassStudentScopes() { assert.ok(auditLogs.items?.some(item => item.action === 'tenant.class_member.upserted'), 'audit logs should include class member upsert'); } +async function testTenantStudentOperations() { + let bulkStudentUserId = ''; + const bulk = await request('/api/tenant-admin/students/bulk-upsert', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + students: [ + { + username: 'integration_bulk_student', + phone: '13800000018', + name: 'Integration Bulk Student', + regionId: ids.region, + legacyUserId: 'legacy-bulk-student', + status: 'active', + stats: { source: 'integration' }, + }, + { + username: 'integration_bulk_missing_region', + phone: '13800000019', + name: 'Integration Bulk Missing Region', + regionId: '00000000-0000-0000-0000-ffffffffffff', + }, + ], + }, + }); + assert.equal(bulk.total, 2, 'bulk student upsert should report total'); + assert.equal(bulk.successCount, 1, 'bulk student upsert should import valid student'); + assert.equal(bulk.errorCount, 1, 'bulk student upsert should report invalid rows'); + bulkStudentUserId = bulk.items?.find(item => item.status === 'active')?.userId || ''; + assert.ok(bulkStudentUserId, 'bulk result should include created student id'); + assert.ok(bulk.errors?.some(item => item.code === 'REGION_NOT_FOUND'), 'bulk result should include row-level validation error'); + + const bulkLimitDenied = await request('/api/tenant-admin/students/bulk-upsert', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + students: Array.from({ length: 201 }, (_, index) => ({ + username: `too_many_${index}`, + phone: `13988${String(index).padStart(6, '0')}`, + })), + }, + expectStatus: 413, + }); + assert.ok( + ['BULK_LIMIT_EXCEEDED', 'JSON_BODY_TOO_LARGE'].includes(bulkLimitDenied.code), + 'bulk student upsert should enforce item or body limit', + ); + + const bulkAssign = await request('/api/tenant-admin/classes/members/bulk-assign', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + classId: ids.tenantClass, + assignments: [ + { userId: bulkStudentUserId, memberType: 'student', status: 'active' }, + { userId: '00000000-0000-0000-0000-ffffffffffff', memberType: 'student' }, + ], + }, + }); + assert.equal(bulkAssign.total, 2, 'bulk class assign should report total'); + assert.equal(bulkAssign.successCount, 1, 'bulk class assign should assign valid student'); + assert.equal(bulkAssign.errorCount, 1, 'bulk class assign should report invalid rows'); + + const adminClassStudents = await request('/api/tenant-admin/students', { + userId: TENANT_ADMIN_USER_ID, + query: { classId: ids.tenantClass, keyword: 'Bulk Student' }, + }); + assert.ok(adminClassStudents.items?.some(item => item.userId === bulkStudentUserId), 'bulk assigned student should appear in class student list'); + + const disabled = await request('/api/tenant-admin/students/status', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + userId: bulkStudentUserId, + status: 'disabled', + reason: 'integration-test', + }, + }); + assert.equal(disabled.item?.status, 'disabled', 'tenant admin should disable student membership'); + + const disabledStudents = await request('/api/tenant-admin/students', { + userId: TENANT_ADMIN_USER_ID, + query: { status: 'disabled', keyword: 'Bulk Student' }, + }); + assert.ok(disabledStudents.items?.some(item => item.userId === bulkStudentUserId), 'disabled student should be queryable by status'); + + const restored = await request('/api/tenant-admin/students/status', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + userId: bulkStudentUserId, + status: 'active', + reason: 'restore-integration-test', + }, + }); + assert.equal(restored.item?.status, 'active', 'tenant admin should restore student membership'); + + const teacherNote = await request('/api/tenant-admin/students/notes', { + userId: TENANT_TEACHER_USER_ID, + method: 'PUT', + body: { + studentUserId: USER_ID, + noteType: 'learning', + content: '该学生本周错题复习需要跟进。', + visibility: 'class_staff', + isPinned: true, + metadata: { source: 'integration' }, + }, + }); + assert.equal(teacherNote.item?.noteType, 'learning', 'teacher should create note for scoped student'); + + const teacherNotes = await request('/api/tenant-admin/students/notes', { + userId: TENANT_TEACHER_USER_ID, + query: { studentUserId: USER_ID }, + }); + assert.ok(teacherNotes.items?.some(item => item.id === teacherNote.item.id), 'teacher should list scoped student notes'); + + const teacherOtherNoteDenied = await request('/api/tenant-admin/students/notes', { + userId: TENANT_TEACHER_USER_ID, + method: 'PUT', + body: { + studentUserId: SECOND_STUDENT_USER_ID, + noteType: 'learning', + content: '不应允许教师给非负责学生写备注。', + }, + expectStatus: 403, + }); + assert.equal(teacherOtherNoteDenied.code, 'STUDENT_SCOPE_REQUIRED', 'teacher should not write note for unscoped student'); + + const followup = await request('/api/tenant-admin/students/followups', { + userId: TENANT_TEACHER_USER_ID, + method: 'PUT', + body: { + studentUserId: USER_ID, + assignedToUserId: TENANT_TEACHER_USER_ID, + classId: ids.tenantClass, + title: '错题复盘督导', + description: '提醒学生完成本周错题复盘。', + followupType: 'learning', + priority: 'high', + status: 'open', + dueAt: '2026-07-01T10:00:00.000Z', + }, + }); + assert.equal(followup.item?.status, 'open', 'teacher should create follow-up for scoped student'); + + const teacherFollowups = await request('/api/tenant-admin/students/followups', { + userId: TENANT_TEACHER_USER_ID, + query: { studentUserId: USER_ID, status: 'open' }, + }); + assert.ok(teacherFollowups.items?.some(item => item.id === followup.item.id), 'teacher should list scoped follow-ups'); + const visibleFollowup = teacherFollowups.items?.find(item => item.id === followup.item.id); + assert.equal(visibleFollowup?.studentPhone, null, 'teacher follow-up list should mask student phone'); + + const completedFollowup = await request('/api/tenant-admin/students/followups', { + userId: TENANT_TEACHER_USER_ID, + method: 'PUT', + body: { + id: followup.item.id, + studentUserId: USER_ID, + assignedToUserId: TENANT_TEACHER_USER_ID, + classId: ids.tenantClass, + title: '错题复盘督导', + followupType: 'learning', + priority: 'high', + status: 'done', + }, + }); + assert.equal(completedFollowup.item?.status, 'done', 'teacher should complete scoped follow-up'); + assert.equal(completedFollowup.item?.completedBy, TENANT_TEACHER_USER_ID, 'completed follow-up should record completer'); + + const teacherOtherFollowupDenied = await request('/api/tenant-admin/students/followups', { + userId: TENANT_TEACHER_USER_ID, + method: 'PUT', + body: { + studentUserId: SECOND_STUDENT_USER_ID, + classId: ids.tenantClassOther, + title: '不应创建', + followupType: 'learning', + }, + expectStatus: 403, + }); + assert.equal(teacherOtherFollowupDenied.code, 'STUDENT_SCOPE_REQUIRED', 'teacher should not create follow-up for unscoped student'); + + const studentStatusDenied = await request('/api/tenant-admin/students/status', { + userId: TENANT_TEACHER_USER_ID, + method: 'POST', + body: { + userId: USER_ID, + status: 'disabled', + }, + expectStatus: 403, + }); + assert.equal(studentStatusDenied.code, 'TENANT_PERMISSION_REQUIRED', 'teacher should not disable students without status permission'); + + const partnerBulkDenied = await request('/api/tenant-admin/students/bulk-upsert', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + students: [{ userId: USER_ID, name: 'cross tenant denied' }], + }, + expectStatus: 403, + }); + assert.equal(partnerBulkDenied.code, 'TENANT_ADMIN_REQUIRED', 'student bulk import must be tenant isolated'); + + const auditLogs = await request('/api/tenant-admin/audit-logs', { + userId: TENANT_ADMIN_USER_ID, + query: { action: 'tenant.student', limit: 100 }, + }); + assert.ok(auditLogs.items?.some(item => item.action === 'tenant.students.bulk_upserted'), 'audit logs should include bulk student upsert'); + assert.ok(auditLogs.items?.some(item => item.action === 'tenant.student.status_updated'), 'audit logs should include student status update'); + assert.ok(auditLogs.items?.some(item => item.action === 'tenant.student_note.upserted'), 'audit logs should include student note upsert'); + assert.ok(auditLogs.items?.some(item => item.action === 'tenant.student_followup.upserted'), 'audit logs should include student follow-up upsert'); +} + async function testReferralAndCrmGrowth() { const salesMember = await request('/api/tenant-admin/members', { userId: TENANT_ADMIN_USER_ID, @@ -2958,6 +3174,7 @@ async function main() { await check('tenant admin operations', testTenantAdminOps); await check('tenant member permissions and audit', testTenantMemberPermissionsAndAudit); await check('tenant class and student scopes', testTenantClassStudentScopes); + await check('tenant student operations', testTenantStudentOperations); await check('referral and CRM growth', testReferralAndCrmGrowth); console.log('API integration tests complete.'); diff --git a/supabase/migrations/202606290003_student_operations.sql b/supabase/migrations/202606290003_student_operations.sql new file mode 100644 index 00000000..39b3e807 --- /dev/null +++ b/supabase/migrations/202606290003_student_operations.sql @@ -0,0 +1,92 @@ +create table if not exists public.tenant_student_notes ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + student_user_id uuid not null references public.platform_users(id) on delete cascade, + note_type text not null default 'general' + check (note_type in ('general', 'learning', 'service', 'sales', 'risk', 'follow_up')), + content text not null, + visibility text not null default 'tenant_staff' + check (visibility in ('tenant_staff', 'class_staff', 'author_only')), + is_pinned boolean not null default false, + metadata jsonb not null default '{}'::jsonb, + created_by uuid references public.platform_users(id) on delete set null, + updated_by uuid references public.platform_users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +comment on table public.tenant_student_notes is + 'Tenant-scoped student operation notes for teachers, class advisers, operators and sales follow-up.'; + +create index if not exists idx_student_notes_tenant_student + on public.tenant_student_notes(tenant_id, student_user_id, is_pinned desc, created_at desc); + +create index if not exists idx_student_notes_tenant_creator + on public.tenant_student_notes(tenant_id, created_by, created_at desc) + where created_by is not null; + +create table if not exists public.tenant_student_followups ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + student_user_id uuid not null references public.platform_users(id) on delete cascade, + assigned_to_user_id uuid references public.platform_users(id) on delete set null, + class_id uuid, + title text not null, + description text, + followup_type text not null default 'learning' + check (followup_type in ('learning', 'service', 'sales', 'renewal', 'risk', 'custom')), + priority text not null default 'normal' + check (priority in ('low', 'normal', 'high', 'urgent')), + status text not null default 'open' + check (status in ('open', 'in_progress', 'done', 'cancelled')), + due_at timestamptz, + completed_at timestamptz, + completed_by uuid references public.platform_users(id) on delete set null, + metadata jsonb not null default '{}'::jsonb, + created_by uuid references public.platform_users(id) on delete set null, + updated_by uuid references public.platform_users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + foreign key (class_id) references public.tenant_classes(id) on delete set null +); + +comment on table public.tenant_student_followups is + 'Tenant-scoped student follow-up tasks for learning supervision, service, sales and renewal operations.'; + +create index if not exists idx_student_followups_tenant_student + on public.tenant_student_followups(tenant_id, student_user_id, status, due_at nulls last, created_at desc); + +create index if not exists idx_student_followups_tenant_assignee + on public.tenant_student_followups(tenant_id, assigned_to_user_id, status, due_at nulls last) + where assigned_to_user_id is not null; + +create index if not exists idx_student_followups_tenant_class + on public.tenant_student_followups(tenant_id, class_id, status, due_at nulls last) + where class_id is not null; + +alter table public.tenant_student_notes enable row level security; +alter table public.tenant_student_followups enable row level security; + +drop policy if exists tenant_isolation on public.tenant_student_notes; +create policy tenant_isolation on public.tenant_student_notes + for all + using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) + with check (tenant_id = app.current_tenant_id() or app.is_platform_admin()); + +drop policy if exists tenant_isolation on public.tenant_student_followups; +create policy tenant_isolation on public.tenant_student_followups + for all + using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) + with check (tenant_id = app.current_tenant_id() or app.is_platform_admin()); + +drop trigger if exists set_updated_at on public.tenant_student_notes; +create trigger set_updated_at + before update on public.tenant_student_notes + for each row + execute function app.touch_updated_at(); + +drop trigger if exists set_updated_at on public.tenant_student_followups; +create trigger set_updated_at + before update on public.tenant_student_followups + for each row + execute function app.touch_updated_at();