feat: add tenant student operations

This commit is contained in:
Codex
2026-06-29 01:16:25 +08:00
parent 61240c5833
commit 726d090a8e
12 changed files with 908 additions and 24 deletions

View File

@@ -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、公共题库授权租户采纳、题目反馈、签到积分和排行榜

View File

@@ -14,7 +14,7 @@ const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
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: '角色模板查看' },

View File

@@ -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<T extends Record<string, unknown>>(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<Record<string, unknown>>(
`
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<Record<string, unknown>>(
`
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 };
}

View File

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

View File

@@ -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 |

View File

@@ -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 校验仍需继续补。

View File

@@ -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 深测和自定义角色权限细化。

View File

@@ -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` 备注只能由作者或全局学生管理权限账号更新。
后续要补:

View File

@@ -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 择校推荐
- 地区考试数据上下文。

View File

@@ -172,8 +172,10 @@ tenant:<tenantId>: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`
- 内容入口/分类树/题目集合/练习蓝图
- 题目/单词/知识手册/分数线/视频维护

View File

@@ -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.');

View File

@@ -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();