diff --git a/README.md b/README.md index 7fdb1ed4..2f7291fd 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ ## 当前状态 -更新时间:2026-06-28 +更新时间:2026-06-29 目前已经完成并在本地验证通过的内容: - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 - `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 -- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、自定义角色模板、审计日志。 +- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、自定义角色模板、班级/教师/学生范围权限、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册 JSON 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。 @@ -22,7 +22,7 @@ 还没有达到生产交付的部分: -- Supabase Auth/JWT 已可联调;生产前还要做真实云端 Auth/JWKS 回归、RLS 深测和自定义角色权限细化。 +- Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。 - 真实短信、微信登录、QQ 登录、微信支付、支付宝等 provider adapter 还没接完。 - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF 预览、防盗链和视频水印还没完成。 - Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。 @@ -138,7 +138,7 @@ apps/api/src/features/ referral/ 销售/代理客资追踪、CRM 队列 scoreline/ 分数线 tenant/ 租户解析 - tenant-admin/ 租户后台配置、成员权限、活动和审计 + tenant-admin/ 租户后台配置、成员权限、班级学生、活动和审计 tenant-content/ 租户内容导航、题库维护、资源管理、批量导入 video/ 题目视频讲解 ``` @@ -174,8 +174,8 @@ npm run check:refactor 优先继续补: -1. 真实云端 Auth/JWKS 回归、RLS 深测、班级/教师/学生范围权限。 +1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 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 fd89f50e..887be245 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:*'], + teacher: ['content:*', 'classes:read', 'students:read'], sales: ['codes:*', 'coupons:*', 'referral:*'], agent: ['codes:read', 'coupons:read', 'referral:self'], student: [], @@ -102,6 +102,10 @@ export function tenantPermissionCatalog() { { key: 'referral:write', label: '客资归属管理' }, { key: 'crm:read', label: 'CRM 队列查看' }, { key: 'crm:write', label: 'CRM 入队和重试' }, + { key: 'classes:read', label: '班级查看' }, + { key: 'classes:write', label: '班级管理' }, + { key: 'students:read', label: '学生查看' }, + { key: 'students: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 new file mode 100644 index 00000000..abef0b7b --- /dev/null +++ b/apps/api/src/features/tenant-admin/classes.ts @@ -0,0 +1,724 @@ +import type pg from 'pg'; +import { HttpError, type RequestContext } from '../../core/http.js'; +import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js'; +import { query, transaction } from '../../core/db.js'; +import { + hasTenantPermission, + requireTenantAdmin, + requireTenantPermission, + type TenantAdminAuth, +} from './auth.js'; + +type JsonBody = Record; + +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']; + +function nullableString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function jsonBodyValue(value: unknown) { + return JSON.stringify(objectValue(value)); +} + +function intValue(value: unknown, fallback: number) { + const numberValue = Number(value ?? fallback); + return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback; +} + +function optionalChoice(value: unknown, allowed: string[], fallback: string) { + const candidate = nullableString(value) || fallback; + if (!allowed.includes(candidate)) { + throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE'); + } + return candidate; +} + +function uuidArrayValue(value: unknown) { + if (!Array.isArray(value)) return []; + return value + .map(item => (typeof item === 'string' ? item.trim() : '')) + .filter(item => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(item)); +} + +function classCodeValue(value: unknown) { + const code = nullableString(value); + if (!code) return null; + if (!/^[a-z0-9][a-z0-9_-]{1,63}$/i.test(code)) { + throw new HttpError(400, 'Invalid class code', 'INVALID_CLASS_CODE'); + } + return code; +} + +function canReadAllClassScope(auth: TenantAdminAuth) { + return ( + auth.role === 'tenant_owner' || + auth.role === 'tenant_admin' || + hasTenantPermission(auth, 'classes:write') || + hasTenantPermission(auth, 'students:write') || + hasTenantPermission(auth, 'members:read') + ); +} + +function canSeeStudentPhone(auth: TenantAdminAuth) { + return auth.fieldPermissions?.['student.phone'] !== false; +} + +function maskStudentFields>(auth: TenantAdminAuth, item: T): T { + if (canSeeStudentPhone(auth)) return item; + return { + ...item, + phone: null, + }; +} + +async function recordAudit( + client: pg.PoolClient, + auth: TenantAdminAuth, + action: string, + targetType: string, + targetId: string | null, + details: Record = {}, +) { + await client.query( + ` + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, $2, $3, $4, $5, $6::jsonb) + `, + [auth.tenantId, auth.userId, action, targetType, targetId, JSON.stringify(details)], + ); +} + +async function scopedClassIds(auth: TenantAdminAuth) { + if (canReadAllClassScope(auth)) return null; + + const explicit = uuidArrayValue(auth.dataScope?.classIds); + const rows = await query<{ id: string }>( + ` + select distinct class_id as id + from public.tenant_class_members + where tenant_id = $1 + and user_id = $2 + and status = 'active' + and member_type in ('teacher', 'assistant', 'head_teacher') + `, + [auth.tenantId, auth.userId], + ); + + return Array.from(new Set([...explicit, ...rows.map(item => item.id)])); +} + +async function ensureReadableClass(auth: TenantAdminAuth, classId: string) { + const allowedClassIds = await scopedClassIds(auth); + if (allowedClassIds && !allowedClassIds.includes(classId)) { + throw new HttpError(403, 'Class is outside the current data scope', 'CLASS_SCOPE_REQUIRED'); + } + + const rows = await query<{ id: string }>( + 'select id from public.tenant_classes where tenant_id = $1 and id = $2 limit 1', + [auth.tenantId, classId], + ); + if (!rows[0]) throw new HttpError(404, 'Class not found', 'CLASS_NOT_FOUND'); +} + +async function ensureTenantReference( + client: pg.PoolClient, + tableName: 'regions' | 'schools' | 'majors', + tenantId: string, + id: string | null, + errorCode: string, +) { + if (!id) return; + const result = await client.query<{ id: string }>( + `select id from public.${tableName} where tenant_id = $1 and id = $2 limit 1`, + [tenantId, id], + ); + if (!result.rows[0]) throw new HttpError(400, `${tableName} id is not in this tenant`, errorCode); +} + +async function ensureTenantClass(client: pg.PoolClient, tenantId: string, classId: string) { + const result = await client.query<{ id: string }>( + 'select id from public.tenant_classes where tenant_id = $1 and id = $2 limit 1', + [tenantId, classId], + ); + if (!result.rows[0]) throw new HttpError(404, 'Class not found', 'CLASS_NOT_FOUND'); +} + +async function resolveOrCreateUser(client: pg.PoolClient, body: JsonBody, fallbackPrimaryRole: string) { + const userId = nullableString(body.userId); + if (userId) { + const existing = await client.query<{ id: string }>( + 'select id from public.platform_users where id = $1 limit 1', + [userId], + ); + if (!existing.rows[0]) throw new HttpError(404, 'User not found', 'USER_NOT_FOUND'); + await client.query( + ` + update public.platform_users + set username = coalesce($2, username), + email = coalesce($3::citext, email), + phone = coalesce($4, phone), + name = coalesce($5, name), + avatar_url = coalesce($6, avatar_url), + primary_role = coalesce($7, primary_role), + updated_at = now() + where id = $1 + `, + [ + userId, + nullableString(body.username), + nullableString(body.email), + nullableString(body.phone), + nullableString(body.name), + nullableString(body.avatarUrl), + nullableString(body.primaryRole), + ], + ); + return userId; + } + + const phone = nullableString(body.phone); + const email = nullableString(body.email); + const username = nullableString(body.username); + const name = nullableString(body.name); + if (!phone && !email && !username && !name) { + throw new HttpError(400, 'userId, phone, email, username, or name is required', 'USER_REQUIRED'); + } + + const found = await client.query<{ id: string }>( + ` + select id + from public.platform_users + where ($1::text is not null and phone = $1) + or ($2::citext is not null and email = $2::citext) + or ($3::text is not null and username = $3) + order by created_at asc + limit 1 + `, + [phone, email, username], + ); + if (found.rows[0]) return found.rows[0].id; + + const created = await client.query<{ id: string }>( + ` + insert into public.platform_users (username, email, phone, name, avatar_url, primary_role, raw_profile) + values ($1, $2::citext, $3, $4, $5, $6, '{"source":"tenant-admin"}'::jsonb) + returning id + `, + [ + username || phone || email, + email, + phone, + name || username || phone || email, + nullableString(body.avatarUrl), + fallbackPrimaryRole, + ], + ); + return created.rows[0].id; +} + +async function ensureTenantMembership( + client: pg.PoolClient, + tenantId: string, + userId: string, + role: string, + status = 'active', +) { + await client.query( + ` + insert into public.tenant_memberships (tenant_id, user_id, role, status) + values ($1, $2, $3, $4) + on conflict (tenant_id, user_id, role) + do update set status = excluded.status, + updated_at = now() + `, + [tenantId, userId, role, status], + ); +} + +function membershipRoleForClassMember(memberType: string) { + return memberType === 'student' ? 'student' : 'teacher'; +} + +export async function tenantClassesRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'classes:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const status = stringParam(ctx, 'status'); + const regionId = stringParam(ctx, 'regionId'); + const keyword = stringParam(ctx, 'keyword'); + const scopedIds = await scopedClassIds(auth); + + const params: unknown[] = [auth.tenantId]; + const filters = ['tc.tenant_id = $1']; + if (status) { + if (!CLASS_STATUSES.includes(status)) throw new HttpError(400, `Invalid class status: ${status}`, 'INVALID_CLASS_STATUS'); + params.push(status); + filters.push(`tc.status = $${params.length}`); + } else { + filters.push(`tc.status <> 'archived'`); + } + if (regionId) { + params.push(regionId); + filters.push(`tc.region_id = $${params.length}::uuid`); + } + if (keyword) { + params.push(`%${keyword}%`); + filters.push(`(tc.name ilike $${params.length} or tc.code ilike $${params.length})`); + } + if (scopedIds) { + params.push(scopedIds); + filters.push(`tc.id = any($${params.length}::uuid[])`); + } + params.push(limit); + + const items = await query( + ` + select tc.id, tc.region_id as "regionId", r.name as "regionName", + tc.legacy_id as "legacyId", tc.code, tc.name, tc.description, + tc.status, tc.sort_order as "sortOrder", tc.metadata, + tc.created_by as "createdBy", tc.updated_by as "updatedBy", + tc.created_at as "createdAt", tc.updated_at as "updatedAt", + coalesce(count(tcm.id) filter (where tcm.status = 'active' and tcm.member_type = 'student'), 0)::int as "studentCount", + coalesce(count(tcm.id) filter (where tcm.status = 'active' and tcm.member_type in ('teacher', 'assistant', 'head_teacher')), 0)::int as "teacherCount" + from public.tenant_classes tc + left join public.regions r on r.id = tc.region_id and r.tenant_id = tc.tenant_id + left join public.tenant_class_members tcm on tcm.tenant_id = tc.tenant_id and tcm.class_id = tc.id + where ${filters.join(' and ')} + group by tc.id, r.name + order by tc.sort_order asc, tc.created_at desc + limit $${params.length} + `, + params, + ); + return { items, scoped: scopedIds !== null }; +} + +export async function upsertTenantClassRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'classes:write'); + const body = await readJsonBody(ctx); + const item = await transaction(async client => { + const regionId = nullableString(body.regionId); + await ensureTenantReference(client, 'regions', auth.tenantId, regionId, 'REGION_NOT_FOUND'); + + const result = await client.query( + ` + insert into public.tenant_classes ( + id, tenant_id, region_id, legacy_id, code, name, description, + status, sort_order, metadata, created_by, updated_by + ) + values ( + coalesce($2::uuid, gen_random_uuid()), $1, $3::uuid, $4, $5, $6, $7, + $8, $9, $10::jsonb, $11, $11 + ) + on conflict (id) + do update set region_id = excluded.region_id, + legacy_id = coalesce(excluded.legacy_id, public.tenant_classes.legacy_id), + code = excluded.code, + name = excluded.name, + description = excluded.description, + status = excluded.status, + sort_order = excluded.sort_order, + metadata = excluded.metadata, + updated_by = excluded.updated_by, + updated_at = now() + where public.tenant_classes.tenant_id = excluded.tenant_id + returning id, region_id as "regionId", legacy_id as "legacyId", code, name, + description, status, sort_order as "sortOrder", metadata, + created_by as "createdBy", updated_by as "updatedBy", + created_at as "createdAt", updated_at as "updatedAt" + `, + [ + auth.tenantId, + nullableString(body.id), + regionId, + nullableString(body.legacyId), + classCodeValue(body.code), + requiredString(body, 'name'), + nullableString(body.description), + optionalChoice(body.status, CLASS_STATUSES, 'active'), + intValue(body.sortOrder, 0), + jsonBodyValue(body.metadata), + auth.userId, + ], + ); + if (!result.rows[0]) throw new HttpError(404, 'Class not found for this tenant', 'CLASS_NOT_FOUND'); + await recordAudit(client, auth, 'tenant.class.upserted', 'tenant_classes', result.rows[0].id, { + code: result.rows[0].code, + name: result.rows[0].name, + }); + return result.rows[0]; + }); + return { item }; +} + +export async function disableTenantClassRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'classes:write'); + const body = await readJsonBody(ctx); + const classId = requiredString(body, 'classId'); + const item = await transaction(async client => { + const result = await client.query( + ` + update public.tenant_classes + set status = 'disabled', + updated_by = $3, + updated_at = now() + where tenant_id = $1 and id = $2 + returning id, code, name, status, updated_at as "updatedAt" + `, + [auth.tenantId, classId, auth.userId], + ); + if (!result.rows[0]) throw new HttpError(404, 'Class not found', 'CLASS_NOT_FOUND'); + await recordAudit(client, auth, 'tenant.class.disabled', 'tenant_classes', classId); + return result.rows[0]; + }); + return { item }; +} + +export async function tenantClassMembersRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'classes:read'); + const classId = stringParam(ctx, 'classId'); + if (!classId) throw new HttpError(400, 'classId is required', 'CLASS_ID_REQUIRED'); + await ensureReadableClass(auth, classId); + const memberType = stringParam(ctx, 'memberType'); + const status = stringParam(ctx, 'status') || 'active'; + if (memberType && !CLASS_MEMBER_TYPES.includes(memberType)) { + throw new HttpError(400, `Invalid member type: ${memberType}`, 'INVALID_CLASS_MEMBER_TYPE'); + } + if (!CLASS_MEMBER_STATUSES.includes(status)) { + throw new HttpError(400, `Invalid class member status: ${status}`, 'INVALID_CLASS_MEMBER_STATUS'); + } + const params: unknown[] = [auth.tenantId, classId, status]; + const filters = ['tcm.tenant_id = $1', 'tcm.class_id = $2::uuid', 'tcm.status = $3']; + if (memberType) { + params.push(memberType); + filters.push(`tcm.member_type = $${params.length}`); + } + + const items = await query>( + ` + select tcm.id, tcm.class_id as "classId", tcm.user_id as "userId", + tcm.member_type as "memberType", tcm.status, tcm.joined_at as "joinedAt", + tcm.left_at as "leftAt", tcm.metadata, tcm.created_at as "createdAt", + tcm.updated_at as "updatedAt", + u.username, u.email::text as email, u.phone, u.name, + u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", + sp.region_id as "regionId", sp.selected_school_id as "selectedSchoolId", + sp.selected_major_id as "selectedMajorId" + from public.tenant_class_members tcm + join public.platform_users u on u.id = tcm.user_id + left join public.student_profiles sp on sp.tenant_id = tcm.tenant_id and sp.user_id = tcm.user_id + where ${filters.join(' and ')} + order by case tcm.member_type + when 'head_teacher' then 1 + when 'teacher' then 2 + when 'assistant' then 3 + else 9 + end, tcm.joined_at asc + `, + params, + ); + + return { items: items.map(item => maskStudentFields(auth, item)) }; +} + +export async function upsertTenantClassMemberRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'classes:write'); + const body = await readJsonBody(ctx); + const classId = requiredString(body, 'classId'); + const memberType = optionalChoice(body.memberType, CLASS_MEMBER_TYPES, 'student'); + const status = optionalChoice(body.status, CLASS_MEMBER_STATUSES, 'active'); + + const item = await transaction(async client => { + await ensureTenantClass(client, auth.tenantId, classId); + const userId = await resolveOrCreateUser(client, body, 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 result = 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, joined_at as "joinedAt", + left_at as "leftAt", metadata, created_at as "createdAt", updated_at as "updatedAt" + `, + [auth.tenantId, classId, userId, memberType, status, jsonBodyValue(body.metadata), auth.userId], + ); + + await recordAudit(client, auth, 'tenant.class_member.upserted', 'tenant_class_members', result.rows[0].id, { + classId, + userId, + memberType, + status, + }); + return result.rows[0]; + }); + + return { item }; +} + +export async function removeTenantClassMemberRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'classes:write'); + const body = await readJsonBody(ctx); + const classMemberId = requiredString(body, 'classMemberId'); + + const item = await transaction(async client => { + const result = await client.query( + ` + update public.tenant_class_members + set status = 'removed', + left_at = now(), + updated_by = $3, + updated_at = now() + where tenant_id = $1 and id = $2 + returning id, class_id as "classId", user_id as "userId", + member_type as "memberType", status, left_at as "leftAt", + updated_at as "updatedAt" + `, + [auth.tenantId, classMemberId, auth.userId], + ); + if (!result.rows[0]) throw new HttpError(404, 'Class member not found', 'CLASS_MEMBER_NOT_FOUND'); + await recordAudit(client, auth, 'tenant.class_member.removed', 'tenant_class_members', classMemberId, { + classId: result.rows[0].classId, + userId: result.rows[0].userId, + }); + return result.rows[0]; + }); + + return { item }; +} + +export async function tenantStudentsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'students:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const keyword = stringParam(ctx, 'keyword'); + const status = stringParam(ctx, 'status') || 'active'; + const regionId = stringParam(ctx, 'regionId'); + const classId = stringParam(ctx, 'classId'); + const scopedIds = await scopedClassIds(auth); + if (classId) await ensureReadableClass(auth, classId); + if (!STUDENT_MEMBER_STATUSES.includes(status)) { + throw new HttpError(400, `Invalid student status: ${status}`, 'INVALID_STUDENT_STATUS'); + } + + const params: unknown[] = [auth.tenantId, status]; + let classAggScopeSql = ''; + if (scopedIds) { + params.push(scopedIds); + classAggScopeSql = `and tcm.class_id = any($${params.length}::uuid[])`; + } + const filters = ['tm.tenant_id = $1', `tm.role = 'student'`, 'tm.status = $2']; + if (keyword) { + params.push(`%${keyword}%`); + filters.push(`(u.username ilike $${params.length} or u.name ilike $${params.length} or u.phone ilike $${params.length} or u.email::text ilike $${params.length})`); + } + if (regionId) { + params.push(regionId); + filters.push(`sp.region_id = $${params.length}::uuid`); + } + if (classId) { + params.push(classId); + 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 = $${params.length}::uuid + and scoped_cm.member_type = 'student' + and scoped_cm.status = 'active' + )`); + } + 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' + )`); + } + params.push(limit); + + const items = await query>( + ` + with class_agg as ( + select tcm.tenant_id, tcm.user_id, + jsonb_agg( + jsonb_build_object( + 'classId', tc.id, + 'className', tc.name, + 'classCode', tc.code, + 'memberType', tcm.member_type, + 'joinedAt', tcm.joined_at + ) + order by tc.sort_order asc, tc.created_at desc + ) filter (where tcm.status = 'active') as classes + from public.tenant_class_members tcm + join public.tenant_classes tc on tc.tenant_id = tcm.tenant_id and tc.id = tcm.class_id + where tcm.tenant_id = $1 and tcm.member_type = 'student' + ${classAggScopeSql} + group by tcm.tenant_id, tcm.user_id + ) + select tm.id as "membershipId", tm.user_id as "userId", tm.status, + tm.created_at as "memberCreatedAt", tm.updated_at as "memberUpdatedAt", + u.username, u.email::text as email, u.phone, u.name, + u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", + u.last_seen_at as "lastSeenAt", + sp.id as "profileId", sp.region_id as "regionId", r.name as "regionName", + sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName", + sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName", + sp.questions_answered_today as "questionsAnsweredToday", + sp.mastered_words_count as "masteredWordsCount", + sp.last_check_in_date as "lastCheckInDate", + sp.stats, sp.progress, sp.module_selections as "moduleSelections", + coalesce(ca.classes, '[]'::jsonb) as classes + from public.tenant_memberships tm + join public.platform_users u on u.id = tm.user_id + left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id + left join public.regions r on r.tenant_id = tm.tenant_id and r.id = sp.region_id + left join public.schools s on s.tenant_id = tm.tenant_id and s.id = sp.selected_school_id + left join public.majors m on m.tenant_id = tm.tenant_id and m.id = sp.selected_major_id + left join class_agg ca on ca.tenant_id = tm.tenant_id and ca.user_id = tm.user_id + where ${filters.join(' and ')} + order by tm.created_at desc + limit $${params.length} + `, + params, + ); + + return { items: items.map(item => maskStudentFields(auth, item)), scoped: scopedIds !== null }; +} + +export async function upsertTenantStudentRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'students:write'); + const body = await readJsonBody(ctx); + const status = optionalChoice(body.status, STUDENT_MEMBER_STATUSES, 'active'); + + const item = await transaction(async client => { + const userId = await resolveOrCreateUser(client, body, 'student'); + const regionId = nullableString(body.regionId); + const selectedSchoolId = nullableString(body.selectedSchoolId); + const selectedMajorId = nullableString(body.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, region_id, selected_school_id, selected_major_id, + stats, progress, module_selections + ) + values ($1, $2, $3::uuid, $4::uuid, $5::uuid, $6::jsonb, $7::jsonb, $8::jsonb) + on conflict (tenant_id, user_id) + do update set 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 $6::jsonb = '{}'::jsonb then public.student_profiles.stats else excluded.stats end, + progress = case when $7::jsonb = '{}'::jsonb then public.student_profiles.progress else excluded.progress end, + module_selections = case when $8::jsonb = '{}'::jsonb then public.student_profiles.module_selections else excluded.module_selections end, + updated_at = now() + returning id as "profileId", tenant_id as "tenantId", user_id as "userId", + region_id as "regionId", selected_school_id as "selectedSchoolId", + selected_major_id as "selectedMajorId", stats, progress, + module_selections as "moduleSelections", updated_at as "updatedAt" + `, + [ + auth.tenantId, + userId, + regionId, + selectedSchoolId, + selectedMajorId, + jsonBodyValue(body.stats), + jsonBodyValue(body.progress), + jsonBodyValue(body.moduleSelections), + ], + ); + + await recordAudit(client, auth, 'tenant.student.upserted', 'student_profiles', profile.rows[0].profileId, { + userId, + status, + regionId, + selectedSchoolId, + selectedMajorId, + }); + return profile.rows[0]; + }); + + return { item }; +} + +export async function tenantTeachersRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'classes:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const keyword = stringParam(ctx, 'keyword'); + const params: unknown[] = [auth.tenantId]; + const filters = ['tm.tenant_id = $1', `tm.role = 'teacher'`, `tm.status = 'active'`]; + if (keyword) { + params.push(`%${keyword}%`); + filters.push(`(u.username ilike $${params.length} or u.name ilike $${params.length} or u.phone ilike $${params.length})`); + } + if (!canReadAllClassScope(auth)) { + params.push(auth.userId); + filters.push(`tm.user_id = $${params.length}::uuid`); + } + params.push(limit); + + const items = await query>( + ` + select tm.id as "membershipId", tm.user_id as "userId", tm.status, + u.username, u.email::text as email, u.phone, u.name, + u.avatar_url as "avatarUrl", u.last_seen_at as "lastSeenAt", + coalesce(count(tcm.id) filter (where tcm.status = 'active'), 0)::int as "classCount" + from public.tenant_memberships tm + join public.platform_users u on u.id = tm.user_id + left join public.tenant_class_members tcm + on tcm.tenant_id = tm.tenant_id + and tcm.user_id = tm.user_id + and tcm.member_type in ('teacher', 'assistant', 'head_teacher') + where ${filters.join(' and ')} + group by tm.id, u.id + order by tm.created_at desc + limit $${params.length} + `, + params, + ); + + return { items }; +} diff --git a/apps/api/src/features/tenant-admin/index.ts b/apps/api/src/features/tenant-admin/index.ts index 7459637c..9935e85c 100644 --- a/apps/api/src/features/tenant-admin/index.ts +++ b/apps/api/src/features/tenant-admin/index.ts @@ -1,4 +1,15 @@ import type { RouteDefinition } from '../../core/router.js'; +import { + disableTenantClassRoute, + removeTenantClassMemberRoute, + tenantClassesRoute, + tenantClassMembersRoute, + tenantStudentsRoute, + tenantTeachersRoute, + upsertTenantClassMemberRoute, + upsertTenantClassRoute, + upsertTenantStudentRoute, +} from './classes.js'; import { activationCodesRoute, announcementsAdminRoute, @@ -39,6 +50,15 @@ export const tenantAdminRoutes: RouteDefinition[] = [ ['GET', '/api/tenant-admin/role-templates', tenantRoleTemplatesRoute], ['PUT', '/api/tenant-admin/role-templates', upsertTenantRoleTemplateRoute], ['POST', '/api/tenant-admin/role-templates/disable', disableTenantRoleTemplateRoute], + ['GET', '/api/tenant-admin/classes', tenantClassesRoute], + ['PUT', '/api/tenant-admin/classes', upsertTenantClassRoute], + ['POST', '/api/tenant-admin/classes/disable', disableTenantClassRoute], + ['GET', '/api/tenant-admin/classes/members', tenantClassMembersRoute], + ['PUT', '/api/tenant-admin/classes/members', upsertTenantClassMemberRoute], + ['POST', '/api/tenant-admin/classes/members/remove', removeTenantClassMemberRoute], + ['GET', '/api/tenant-admin/students', tenantStudentsRoute], + ['PUT', '/api/tenant-admin/students', upsertTenantStudentRoute], + ['GET', '/api/tenant-admin/teachers', tenantTeachersRoute], ['GET', '/api/tenant-admin/overview', tenantOverviewRoute], ['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute], ['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute], diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 8211f57e..517a0044 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -1,6 +1,6 @@ # 后端当前能力盘点 -更新时间:2026-06-28 +更新时间:2026-06-29 当前后端已经完成商用 SaaS 题库系统的主干骨架:PostgreSQL 多租户 schema、Node.js 业务 API、PocketBase 数据导入工具、本地 seed、API 集成测试和对象存储签名 provider。 @@ -42,7 +42,8 @@ | 微信网页/QQ OAuth | 待补齐 | 目前仍是 placeholder,需要 code 换 token、回调域名、账号合并和审计 | | 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT;`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 | | 租户角色权限 | 可联调 | `tenant_memberships.role + permissions + role_template_id`,接口有权限点校验 | -| 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`,支持权限、菜单、模块、字段、数据范围配置;前端 UI 和班级/学生范围继续补 | +| 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`,支持权限、菜单、模块、字段、数据范围配置;前端 UI 继续补 | +| 班级/教师/学生范围权限 | 可联调 | `tenant_classes`、`tenant_class_members` + `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`;教师默认只看自己负责班级,字段权限可脱敏学生手机号 | ## 学生端题库主链路 @@ -114,6 +115,7 @@ | 活动、Banner、FAQ、公告 | 可联调 | `/api/tenant-admin/banners`、`faqs`、`announcements` | | 激活码批次/生成/列表 | 可联调 | `/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/platform-admin/*` | | 数据看板聚合接口 | 待补齐 | 表基础已有,缺完整 dashboard API | diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 8d5ced5b..8982e2a1 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -14,11 +14,12 @@ - `commerce`:订单、支付确认、激活码兑换、权益查询。 - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列。 - `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。 - - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、角色模板、权限矩阵、审计查询。 + - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。 - `tenant`:域名/租户解析。 - 鉴权上下文已支持 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` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 - 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。 @@ -135,6 +136,18 @@ GET /api/crm/config PUT /api/crm/config GET /api/crm/queue GET /api/tenant-admin/permissions +GET /api/tenant-admin/role-templates +PUT /api/tenant-admin/role-templates +POST /api/tenant-admin/role-templates/disable +GET /api/tenant-admin/classes +PUT /api/tenant-admin/classes +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 +GET /api/tenant-admin/students +PUT /api/tenant-admin/students +GET /api/tenant-admin/teachers GET /api/tenant-admin/overview PUT /api/tenant-admin/branding PUT /api/tenant-admin/settings @@ -177,7 +190,8 @@ GET /api/tenant-admin/audit-logs - 激活码兑换和支付成功都走同一套 `grantSvipEntitlement` 权益开通逻辑。 - 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。 - `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。 -- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。 +- 当前默认角色:`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/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index d68ce3d3..2bd47256 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -144,7 +144,7 @@ provider event id 幂等 | tenant_owner | `*` | | tenant_admin | `*` | | tenant_operator | 内容、营销、兑换码/优惠券只读、客资/CRM 只读 | -| teacher | 内容维护 | +| teacher | 内容维护、班级查看、学生查看 | | sales | 兑换码、优惠券、客资 | | agent | 兑换码/优惠券只读、本人的客资 | | student | 无后台权限 | @@ -156,11 +156,14 @@ provider event id 幂等 - 模板可保存 `menuPermissions`、`modulePermissions`、`fieldPermissions`、`dataScope`,供 Taro/管理台做菜单、模块、字段可见性和数据范围 UI。 - 模板含 `*`、`tenant_owner`、`tenant_admin` 等管理员级能力时,只有租户 owner 可创建或授予;普通租户管理员不能自造全权限模板。 - 角色模板创建、更新、禁用都会写入 `audit_logs`。 +- `tenant_classes` 和 `tenant_class_members` 提供班级、班主任、教师、助教、学生分组边界。 +- 教师如无 `classes:write`、`students:write`、`members:read` 等全局管理权限,只能查看自己在 `tenant_class_members` 中负责的班级及这些班级下的学生;也可由角色模板 `dataScope.classIds` 显式限定。 +- 学生手机号等敏感字段可由 `fieldPermissions` 控制,后端会对不可见字段返回 `null`,前端不得绕过其它接口补取。 后续要补: - 前端角色模板配置 UI。 -- 班级/教师/学生范围权限。 +- 更细的数据范围 UI,例如地区、题库、销售团队、本人客资、班级学生组合规则。 ## 上线前安全验收清单 @@ -170,6 +173,7 @@ provider event id 幂等 - 跨租户学生读取题目/订单/资料返回拒绝。 - 销售只能查看自己权限范围内客资。 - 代理不能查看其他代理客资。 +- 教师只能查看自己负责班级的学生,不能查看其它班级或跨租户学生。 - 教师不能修改租户商户密钥。 - 学生不能访问租户后台接口。 - 未开通权益不能下载 SVIP 资料或播放会员视频。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index b0eb5715..9059caf1 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -1,6 +1,6 @@ # 后续开发 TODO -更新时间:2026-06-22 +更新时间:2026-06-29 ## 当前后端基线 @@ -17,6 +17,7 @@ - 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。 - 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。 - 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。 +- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 本地验证:`npm run check:refactor` 已通过。 当前更适合进入前端联调前阅读的总览文档: @@ -30,7 +31,7 @@ 1. 生产鉴权 - 已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口;JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份。 - 已覆盖学生、租户管理员、平台管理员、错租户、坏签名、禁用 legacy header 的 API 集成测试。 - - 已补自定义角色模板、菜单/模块/字段级配置 API;继续补真实云端 Auth/JWKS 回归、RLS 深测和班级/学生范围权限。 + - 已补自定义角色模板、菜单/模块/字段级配置 API、班级/学生范围权限;继续补真实云端 Auth/JWKS 回归和 RLS 深测。 - 前端联调时禁止继续使用 `x-user-id`;`x-tenant-id` 只作为租户上下文,不能作为身份依据。 2. 对象存储 @@ -88,7 +89,11 @@ - 套餐销量、运营动态、24h 活跃度、激活码使用情况。 - 销售/代理转化、分佣结算、客资跟进效果。 -8. AI 择校推荐 +8. 学生运营管理 + - 已完成学生列表、学生资料维护、班级分组和教师范围可见。 + - 继续补学生批量导入、禁用/恢复、批量分班、批量 CRM 推送、学习督导任务和家校/班主任备注。 + +9. AI 择校推荐 - 地区考试数据上下文。 - 学生输入 schema。 - AI 返回 JSON schema。 @@ -98,8 +103,9 @@ 1. 自定义角色 - 已完成租户内角色模板、菜单可见、模块可见、字段级权限和权限变更审计基础 API。 + - 已完成班级/教师/学生范围权限 API,教师只能查看自己负责班级的学生。 - 继续补租户后台可视化配置 UI。 - - 继续补班级/教师/学生范围权限。 + - 继续补更细的数据范围 UI,例如地区、题库、销售团队、本人客资、班级学生组合规则。 2. 主题系统 - 平台默认三套主题。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 4a5d5d9f..bea86108 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -171,6 +171,10 @@ tenant::theme | 激活码兑换 | `POST /api/commerce/activation-codes/redeem` | | 个人中心 | `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 /api/tenant-admin/teachers` | ## 练习访问控制契约 @@ -438,6 +442,8 @@ content_entries - 小程序分享路径必须带 tenantCode 和 referral code。 - 用户首绑归属由后端保护,前端不要提供“换绑销售”入口。 - 管理后台菜单按 `GET /api/tenant-admin/permissions` 返回的 `current.permissions`、`current.templatePermissions`、`current.menuPermissions`、`current.modulePermissions` 渲染;接口权限仍以后端校验为准。 +- 教师、班主任、助教类账号进入租户后台时,学生列表以 `GET /api/tenant-admin/students` 返回的 `scoped` 和 `items` 为准;前端不要自行用本地班级 ID 放大查询范围。 +- 学生手机号、订单金额、客资归属等敏感字段按 `fieldPermissions` 控制显示;字段被后端返回为 `null` 时前端展示脱敏占位,不要从其它接口补取。 - H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。 ## 登录对接 @@ -602,6 +608,7 @@ GET /api/commerce/entitlements - 品牌/主题/域名/公开设置 - 支付账户/登录 provider/密钥引用 - 用户与成员权限 +- 班级/教师/学生:`/api/tenant-admin/classes`、`classes/members`、`students`、`teachers` - 角色模板:`GET/PUT /api/tenant-admin/role-templates`、`POST /api/tenant-admin/role-templates/disable` - 内容入口/分类树/题目集合/练习蓝图 - 题目/单词/知识手册/分数线/视频维护 @@ -609,7 +616,7 @@ GET /api/commerce/entitlements - Banner/FAQ/公告/激活码/优惠券 - 销售/代理/CRM 队列 -租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 permission keys 执行真正的访问控制。 +租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 permission keys 执行真正的访问控制。班级/学生范围权限由后端根据角色、模板 `dataScope.classIds` 和 `tenant_class_members` 计算,教师默认只能看到自己负责班级。 ## 联调顺序 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 6b1b6f90..219ec0df 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -13,6 +13,8 @@ const TENANT_ADMIN_USER_ID = process.env.TENANT_ADMIN_USER_ID || '00000000-0000- const TENANT_OPERATOR_USER_ID = '00000000-0000-0000-0000-000000000103'; const TENANT_SALES_USER_ID = '00000000-0000-0000-0000-000000000104'; const TENANT_AGENT_USER_ID = '00000000-0000-0000-0000-000000000105'; +const TENANT_TEACHER_USER_ID = '00000000-0000-0000-0000-000000000106'; +const SECOND_STUDENT_USER_ID = '00000000-0000-0000-0000-000000000107'; const AUTH_USER_ID = '00000000-0000-0000-0000-00000000a101'; const AUTH_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a102'; const AUTH_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a999'; @@ -40,6 +42,8 @@ const ids = { video: '00000000-0000-0000-0000-000000000821', quotaVideo: '00000000-0000-0000-0000-000000000824', scorelineSchool: '00000000-0000-0000-0000-000000000831', + tenantClass: '00000000-0000-0000-0000-000000000851', + tenantClassOther: '00000000-0000-0000-0000-000000000852', }; const paymentFixture = (() => { @@ -2556,6 +2560,208 @@ async function testTenantMemberPermissionsAndAudit() { assert.equal(partnerAuditDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant audit logs must be tenant isolated'); } +async function testTenantClassStudentScopes() { + const permissionMatrix = await request('/api/tenant-admin/permissions', { + userId: TENANT_ADMIN_USER_ID, + }); + assert.ok(permissionMatrix.permissions?.some(item => item.key === 'classes:write'), 'permission matrix should expose class write permission'); + assert.ok(permissionMatrix.permissions?.some(item => item.key === 'students:read'), 'permission matrix should expose student read permission'); + + const createdClass = await request('/api/tenant-admin/classes', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + id: ids.tenantClass, + regionId: ids.region, + code: 'integration-main', + name: '集成测试主班级', + description: '教师和学生范围权限测试', + sortOrder: 1, + metadata: { stage: 'integration' }, + }, + }); + assert.equal(createdClass.item?.id, ids.tenantClass, 'tenant admin should upsert class'); + + const otherClass = await request('/api/tenant-admin/classes', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + id: ids.tenantClassOther, + regionId: ids.region, + code: 'integration-other', + name: '集成测试其他班级', + sortOrder: 2, + }, + }); + assert.equal(otherClass.item?.id, ids.tenantClassOther, 'tenant admin should upsert another class'); + + const teacherTemplate = await request('/api/tenant-admin/role-templates', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + code: 'teacher-class-scope', + name: '教师班级范围模板', + baseRole: 'teacher', + permissions: { + 'classes:read': true, + 'students:read': true, + }, + menuPermissions: { + teachers: true, + students: true, + }, + fieldPermissions: { + 'student.phone': false, + }, + dataScope: { + mode: 'classes', + }, + }, + }); + assert.equal(teacherTemplate.item?.code, 'teacher-class-scope', 'tenant admin should create teacher scope template'); + + const teacher = await request('/api/tenant-admin/members', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + userId: TENANT_TEACHER_USER_ID, + username: 'integration_teacher', + phone: '13800000015', + name: 'Integration Teacher', + role: 'teacher', + roleTemplateId: teacherTemplate.item.id, + status: 'active', + permissions: {}, + }, + }); + assert.equal(teacher.item?.role, 'teacher', 'tenant admin should create teacher member'); + + const student = await request('/api/tenant-admin/students', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + userId: USER_ID, + username: 'smoke_student', + phone: '13800000000', + name: 'Smoke Student', + regionId: ids.region, + status: 'active', + }, + }); + assert.equal(student.item?.userId, USER_ID, 'tenant admin should upsert student profile'); + + const secondStudent = await request('/api/tenant-admin/students', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + userId: SECOND_STUDENT_USER_ID, + username: 'integration_second_student', + phone: '13800000016', + name: 'Integration Second Student', + regionId: ids.region, + status: 'active', + }, + }); + assert.equal(secondStudent.item?.userId, SECOND_STUDENT_USER_ID, 'tenant admin should upsert another student'); + + const teacherAssignment = await request('/api/tenant-admin/classes/members', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + classId: ids.tenantClass, + userId: TENANT_TEACHER_USER_ID, + memberType: 'teacher', + status: 'active', + }, + }); + assert.equal(teacherAssignment.item?.memberType, 'teacher', 'tenant admin should assign teacher to class'); + + const studentAssignment = await request('/api/tenant-admin/classes/members', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + classId: ids.tenantClass, + userId: USER_ID, + memberType: 'student', + status: 'active', + }, + }); + assert.equal(studentAssignment.item?.memberType, 'student', 'tenant admin should assign student to class'); + + const otherStudentAssignment = await request('/api/tenant-admin/classes/members', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + classId: ids.tenantClassOther, + userId: SECOND_STUDENT_USER_ID, + memberType: 'student', + status: 'active', + }, + }); + assert.equal(otherStudentAssignment.item?.classId, ids.tenantClassOther, 'tenant admin should assign second student to another class'); + + const adminClasses = await request('/api/tenant-admin/classes', { + userId: TENANT_ADMIN_USER_ID, + }); + assert.ok(adminClasses.items?.some(item => item.id === ids.tenantClass && item.studentCount >= 1), 'admin should see main class student count'); + assert.ok(adminClasses.items?.some(item => item.id === ids.tenantClassOther), 'admin should see other class'); + + const teacherClasses = await request('/api/tenant-admin/classes', { + userId: TENANT_TEACHER_USER_ID, + }); + assert.equal(teacherClasses.scoped, true, 'teacher class list should be scoped'); + assert.ok(teacherClasses.items?.some(item => item.id === ids.tenantClass), 'teacher should see assigned class'); + assert.ok(!teacherClasses.items?.some(item => item.id === ids.tenantClassOther), 'teacher should not see unassigned class'); + + const teacherStudents = await request('/api/tenant-admin/students', { + userId: TENANT_TEACHER_USER_ID, + }); + assert.equal(teacherStudents.scoped, true, 'teacher student list should be scoped'); + assert.ok(teacherStudents.items?.some(item => item.userId === USER_ID), 'teacher should see student in assigned class'); + assert.ok(!teacherStudents.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'teacher should not see student in unassigned class'); + const visibleStudent = teacherStudents.items?.find(item => item.userId === USER_ID); + assert.equal(visibleStudent?.phone, null, 'teacher role template should mask student phone'); + + const teacherOtherClassDenied = await request('/api/tenant-admin/classes/members', { + userId: TENANT_TEACHER_USER_ID, + query: { classId: ids.tenantClassOther }, + expectStatus: 403, + }); + assert.equal(teacherOtherClassDenied.code, 'CLASS_SCOPE_REQUIRED', 'teacher should not read another class members'); + + const adminStudents = await request('/api/tenant-admin/students', { + userId: TENANT_ADMIN_USER_ID, + query: { classId: ids.tenantClassOther }, + }); + assert.ok(adminStudents.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'tenant admin should filter students by class'); + + const studentDenied = await request('/api/tenant-admin/students', { + userId: USER_ID, + expectStatus: 403, + }); + assert.equal(studentDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant admin student list'); + + const partnerClassDenied = await request('/api/tenant-admin/classes/members', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + classId: ids.tenantClass, + userId: USER_ID, + memberType: 'student', + }, + expectStatus: 403, + }); + assert.equal(partnerClassDenied.code, 'TENANT_ADMIN_REQUIRED', 'tenant admin must not assign another tenant class'); + + const auditLogs = await request('/api/tenant-admin/audit-logs', { + userId: TENANT_ADMIN_USER_ID, + query: { action: 'tenant.class', limit: 50 }, + }); + assert.ok(auditLogs.items?.some(item => item.action === 'tenant.class.upserted'), 'audit logs should include class upsert'); + assert.ok(auditLogs.items?.some(item => item.action === 'tenant.class_member.upserted'), 'audit logs should include class member upsert'); +} + async function testReferralAndCrmGrowth() { const salesMember = await request('/api/tenant-admin/members', { userId: TENANT_ADMIN_USER_ID, @@ -2751,6 +2957,7 @@ async function main() { await check('tenant content assets and imports', testTenantContentAssetsAndImports); await check('tenant admin operations', testTenantAdminOps); await check('tenant member permissions and audit', testTenantMemberPermissionsAndAudit); + await check('tenant class and student scopes', testTenantClassStudentScopes); await check('referral and CRM growth', testReferralAndCrmGrowth); console.log('API integration tests complete.'); diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index e92ad611..857dd974 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -15,6 +15,8 @@ const ids = { tenantOperatorUser: '00000000-0000-0000-0000-000000000103', tenantSalesUser: '00000000-0000-0000-0000-000000000104', tenantAgentUser: '00000000-0000-0000-0000-000000000105', + tenantTeacherUser: '00000000-0000-0000-0000-000000000106', + secondStudentUser: '00000000-0000-0000-0000-000000000107', region: '00000000-0000-0000-0000-000000000301', subject: '00000000-0000-0000-0000-000000000501', category: '00000000-0000-0000-0000-000000000601', @@ -50,6 +52,8 @@ const ids = { scorelineField: '00000000-0000-0000-0000-000000000833', scorelineRecord: '00000000-0000-0000-0000-000000000834', recentPractice: '00000000-0000-0000-0000-000000000841', + tenantClass: '00000000-0000-0000-0000-000000000851', + tenantClassOther: '00000000-0000-0000-0000-000000000852', partnerTenant: '00000000-0000-0000-0000-000000000901', partnerSubscription: '00000000-0000-0000-0000-000000000902', partnerInvoice: '00000000-0000-0000-0000-000000000903', @@ -208,11 +212,11 @@ async function main() { delete from public.referral_team_edges where tenant_id = $1 and ( - member_user_id in ($2::uuid, $3::uuid, $4::uuid) - or leader_user_id in ($2::uuid, $3::uuid, $4::uuid) + member_user_id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid) + or leader_user_id in ($2::uuid, $3::uuid, $4::uuid, $5::uuid) ) `, - [tenantId, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser], + [tenantId, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser, ids.tenantTeacherUser], ); await client.query( @@ -269,7 +273,9 @@ async function main() { values ($1, $2, 'smoke_tenant_operator', '13800000003', 'Smoke Tenant Operator', 'tenant_operator', '{"source":"smoke-seed"}'::jsonb), ($3, null, 'smoke_tenant_sales', '13800000004', 'Smoke Tenant Sales', 'sales', '{"source":"smoke-seed"}'::jsonb), - ($4, null, 'smoke_tenant_agent', '13800000005', 'Smoke Tenant Agent', 'agent', '{"source":"smoke-seed"}'::jsonb) + ($4, null, 'smoke_tenant_agent', '13800000005', 'Smoke Tenant Agent', 'agent', '{"source":"smoke-seed"}'::jsonb), + ($5, null, 'smoke_teacher', '13800000015', 'Smoke Teacher', 'teacher', '{"source":"smoke-seed"}'::jsonb), + ($6, null, 'smoke_second_student', '13800000016', 'Smoke Second Student', 'student', '{"source":"smoke-seed"}'::jsonb) on conflict (id) do update set username = excluded.username, auth_user_id = excluded.auth_user_id, @@ -278,7 +284,14 @@ async function main() { primary_role = excluded.primary_role, updated_at = now() `, - [ids.tenantOperatorUser, ids.authTenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser], + [ + ids.tenantOperatorUser, + ids.authTenantOperatorUser, + ids.tenantSalesUser, + ids.tenantAgentUser, + ids.tenantTeacherUser, + ids.secondStudentUser, + ], ); await client.query( @@ -335,13 +348,15 @@ async function main() { insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions) values ($1, $2, 'tenant_operator', 'active', '{"marketing:read":true}'::jsonb), - ($1, $3, 'platform_admin', 'active', '{"*":true}'::jsonb) + ($1, $3, 'platform_admin', 'active', '{"*":true}'::jsonb), + ($1, $4, 'teacher', 'active', '{"classes:read":true,"students:read":true}'::jsonb), + ($1, $5, 'student', 'active', '{}'::jsonb) on conflict (tenant_id, user_id, role) do update set status = 'active', permissions = excluded.permissions, updated_at = now() `, - [tenantId, ids.tenantOperatorUser, ids.platformAdminUser], + [tenantId, ids.tenantOperatorUser, ids.platformAdminUser, ids.tenantTeacherUser, ids.secondStudentUser], ); await client.query( @@ -353,6 +368,15 @@ async function main() { [tenantId, ids.user], ); + await client.query( + ` + insert into public.student_profiles (tenant_id, user_id, stats, progress) + values ($1, $2, '{"totalAnswered":0,"correctCount":0,"wrongCount":0,"studyDays":1}'::jsonb, '{}'::jsonb) + on conflict (tenant_id, user_id) do nothing + `, + [tenantId, ids.secondStudentUser], + ); + await client.query( ` insert into public.regions (id, tenant_id, legacy_id, name, code, sort_order, is_active) @@ -1028,6 +1052,55 @@ async function main() { [ids.recentPractice, tenantId, ids.user], ); + await client.query( + ` + insert into public.tenant_classes ( + id, tenant_id, region_id, legacy_id, code, name, description, + status, sort_order, metadata, created_by, updated_by + ) + values + ($1, $3, $4, 'smoke-class-main', 'smoke-main', '烟测主班级', '用于教师学生范围权限测试', 'active', 1, '{"source":"smoke-seed"}'::jsonb, $5, $5), + ($2, $3, $4, 'smoke-class-other', 'smoke-other', '烟测其他班级', '用于验证非负责班级不可见', 'active', 2, '{"source":"smoke-seed"}'::jsonb, $5, $5) + on conflict (id) + do update set region_id = excluded.region_id, + code = excluded.code, + name = excluded.name, + description = excluded.description, + status = excluded.status, + sort_order = excluded.sort_order, + metadata = excluded.metadata, + updated_by = excluded.updated_by, + updated_at = now() + `, + [ids.tenantClass, ids.tenantClassOther, tenantId, ids.region, ids.tenantAdminUser], + ); + + await client.query( + ` + insert into public.tenant_class_members ( + tenant_id, class_id, user_id, member_type, status, metadata, created_by, updated_by + ) + values + ($1, $2, $4, 'teacher', 'active', '{"source":"smoke-seed"}'::jsonb, $6, $6), + ($1, $2, $5, 'student', 'active', '{"source":"smoke-seed"}'::jsonb, $6, $6), + ($1, $3, $7, 'student', 'active', '{"source":"smoke-seed"}'::jsonb, $6, $6) + on conflict (tenant_id, class_id, user_id, member_type) + do update set status = excluded.status, + metadata = excluded.metadata, + updated_by = excluded.updated_by, + updated_at = now() + `, + [ + tenantId, + ids.tenantClass, + ids.tenantClassOther, + ids.tenantTeacherUser, + ids.user, + ids.tenantAdminUser, + ids.secondStudentUser, + ], + ); + await client.query( ` insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata) diff --git a/supabase/migrations/202606290002_tenant_classes.sql b/supabase/migrations/202606290002_tenant_classes.sql new file mode 100644 index 00000000..36b8b7ce --- /dev/null +++ b/supabase/migrations/202606290002_tenant_classes.sql @@ -0,0 +1,86 @@ +create table if not exists public.tenant_classes ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + region_id uuid references public.regions(id) on delete set null, + legacy_id text, + code text, + name text not null, + description text, + status text not null default 'active' check (status in ('active', 'disabled', 'archived')), + sort_order integer not null default 0, + 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(), + unique (tenant_id, id), + unique (tenant_id, legacy_id) +); + +comment on table public.tenant_classes is + 'Tenant-scoped class/cohort records used for teacher, head-teacher, assistant and student data scopes.'; + +create unique index if not exists idx_tenant_classes_code_unique + on public.tenant_classes(tenant_id, lower(code)) + where code is not null and code <> ''; + +create index if not exists idx_tenant_classes_tenant_status + on public.tenant_classes(tenant_id, status, sort_order, created_at desc); + +create index if not exists idx_tenant_classes_region + on public.tenant_classes(tenant_id, region_id) + where region_id is not null; + +create table if not exists public.tenant_class_members ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + class_id uuid not null, + user_id uuid not null references public.platform_users(id) on delete cascade, + member_type text not null check (member_type in ('student', 'teacher', 'assistant', 'head_teacher')), + status text not null default 'active' check (status in ('active', 'disabled', 'removed')), + joined_at timestamptz not null default now(), + left_at timestamptz, + 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(), + unique (tenant_id, class_id, user_id, member_type), + foreign key (tenant_id, class_id) references public.tenant_classes(tenant_id, id) on delete cascade +); + +comment on table public.tenant_class_members is + 'Class membership assignments. Students can belong to multiple classes; teachers gain scoped visibility through active class memberships.'; + +create index if not exists idx_class_members_tenant_class + on public.tenant_class_members(tenant_id, class_id, status, member_type); + +create index if not exists idx_class_members_tenant_user + on public.tenant_class_members(tenant_id, user_id, status, member_type); + +alter table public.tenant_classes enable row level security; +alter table public.tenant_class_members enable row level security; + +drop policy if exists tenant_isolation on public.tenant_classes; +create policy tenant_isolation on public.tenant_classes + 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_class_members; +create policy tenant_isolation on public.tenant_class_members + 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_classes; +create trigger set_updated_at + before update on public.tenant_classes + for each row + execute function app.touch_updated_at(); + +drop trigger if exists set_updated_at on public.tenant_class_members; +create trigger set_updated_at + before update on public.tenant_class_members + for each row + execute function app.touch_updated_at();