feat: enforce student avatar presets

This commit is contained in:
Codex
2026-06-30 15:56:01 +08:00
parent ddad2ac04d
commit d94b20b8ed
13 changed files with 110 additions and 28 deletions

View File

@@ -9,7 +9,7 @@ interface LeaderboardRow {
userId: string;
username: string | null;
name: string | null;
avatarUrl: string | null;
avatarPreset: 'male' | 'female';
primaryRole: string;
regionId: string | null;
regionName: string | null;
@@ -96,7 +96,8 @@ function rowToItem(row: RankedRow, currentUserId: string) {
rank: Number(row.rank),
userId: row.userId,
displayName: row.name || row.username || '学员',
avatarUrl: row.avatarUrl,
avatarUrl: `/assets/avatars/default-${row.avatarPreset}.svg`,
avatarPreset: row.avatarPreset,
primaryRole: row.primaryRole,
regionId: row.regionId,
regionName: row.regionName,
@@ -112,7 +113,9 @@ function rowToItem(row: RankedRow, currentUserId: string) {
function baseUsersCte(filters: string[]) {
return `
base_users as (
select u.id as user_id, u.username, u.name, u.avatar_url, u.primary_role,
select u.id as user_id, u.username, u.name,
coalesce(sp.avatar_preset, 'male') as avatar_preset,
u.primary_role,
sp.region_id, r.name as region_name,
coalesce(array_remove(array_agg(distinct tcm.class_id) filter (where tcm.class_id is not null), null), array[]::uuid[]) as class_ids,
coalesce(array_remove(array_agg(distinct tc.name) filter (where tc.name is not null), null), array[]::text[]) as class_names
@@ -130,7 +133,7 @@ function baseUsersCte(filters: string[]) {
and tc.id = tcm.class_id
and tc.status = 'active'
where ${filters.join(' and ')}
group by u.id, u.username, u.name, u.avatar_url, u.primary_role, sp.region_id, r.name
group by u.id, u.username, u.name, sp.avatar_preset, u.primary_role, sp.region_id, r.name
)
`;
}
@@ -139,7 +142,7 @@ function rankedSelect(metricSql: string, limitPlaceholder: string, offsetPlaceho
return `
${metricSql},
ranked as (
select bu.user_id as "userId", bu.username, bu.name, bu.avatar_url as "avatarUrl",
select bu.user_id as "userId", bu.username, bu.name, bu.avatar_preset as "avatarPreset",
bu.primary_role as "primaryRole", bu.region_id as "regionId", bu.region_name as "regionName",
bu.class_ids as "classIds", bu.class_names as "classNames",
coalesce(m.value, 0) as value,
@@ -167,7 +170,7 @@ function currentRankSelect(metricSql: string, userPlaceholder: string) {
return `
${metricSql},
ranked as (
select bu.user_id as "userId", bu.username, bu.name, bu.avatar_url as "avatarUrl",
select bu.user_id as "userId", bu.username, bu.name, bu.avatar_preset as "avatarPreset",
bu.primary_role as "primaryRole", bu.region_id as "regionId", bu.region_name as "regionName",
bu.class_ids as "classIds", bu.class_names as "classNames",
coalesce(m.value, 0) as value,

View File

@@ -22,6 +22,8 @@ const STUDENT_FOLLOWUP_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
const STUDENT_FOLLOWUP_STATUSES = ['open', 'in_progress', 'done', 'cancelled'];
const MAX_BULK_STUDENTS = 200;
const MAX_BULK_CLASS_ASSIGNMENTS = 500;
const STUDENT_AVATAR_FIELD_KEYS = ['avatarUrl', 'avatar_url', 'avatar', 'headimgurl', 'headImgUrl', 'figureurl', 'figureurl_qq_1', 'figureurl_qq_2'];
const STUDENT_PRIMARY_ROLE_FIELD_KEYS = ['primaryRole', 'primary_role'];
function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -35,6 +37,28 @@ function jsonBodyValue(value: unknown) {
return JSON.stringify(objectValue(value));
}
function hasOwnField(value: Record<string, unknown>, fieldNames: string[]) {
return fieldNames.some(fieldName => Object.hasOwn(value, fieldName));
}
function rejectStudentAvatarFields(body: JsonBody) {
if (!hasOwnField(body, STUDENT_AVATAR_FIELD_KEYS)) return;
throw new HttpError(
400,
'Student avatars are preset-only. Do not submit avatar URLs or provider avatar fields.',
'STUDENT_AVATAR_URL_UNSUPPORTED',
);
}
function rejectStudentPrimaryRoleFields(body: JsonBody) {
if (!hasOwnField(body, STUDENT_PRIMARY_ROLE_FIELD_KEYS)) return;
throw new HttpError(
400,
'Student management cannot update platform primary roles. Use tenant memberships and role templates.',
'STUDENT_PRIMARY_ROLE_UNSUPPORTED',
);
}
function intValue(value: unknown, fallback: number) {
const numberValue = Number(value ?? fallback);
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
@@ -235,6 +259,11 @@ async function ensureTenantClass(client: pg.PoolClient, tenantId: string, classI
}
async function resolveOrCreateUser(client: pg.PoolClient, body: JsonBody, fallbackPrimaryRole: string) {
if (fallbackPrimaryRole === 'student') {
rejectStudentAvatarFields(body);
rejectStudentPrimaryRoleFields(body);
}
const avatarUrl = fallbackPrimaryRole === 'student' ? null : nullableString(body.avatarUrl);
const userId = nullableString(body.userId);
if (userId) {
const existing = await client.query<{ id: string }>(
@@ -250,7 +279,6 @@ async function resolveOrCreateUser(client: pg.PoolClient, body: JsonBody, fallba
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
`,
@@ -260,8 +288,7 @@ async function resolveOrCreateUser(client: pg.PoolClient, body: JsonBody, fallba
nullableString(body.email),
nullableString(body.phone),
nullableString(body.name),
nullableString(body.avatarUrl),
nullableString(body.primaryRole),
avatarUrl,
],
);
return userId;
@@ -300,7 +327,7 @@ async function resolveOrCreateUser(client: pg.PoolClient, body: JsonBody, fallba
email,
phone,
name || username || phone || email,
nullableString(body.avatarUrl),
avatarUrl,
fallbackPrimaryRole,
],
);
@@ -495,7 +522,8 @@ export async function tenantClassMembersRoute(ctx: RequestContext) {
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",
case when tcm.member_type = 'student' then null else u.avatar_url end 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
@@ -678,7 +706,7 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
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",
null::text 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",

View File

@@ -58,7 +58,7 @@ export async function tenantUserNotificationsRoute(ctx: RequestContext) {
const items = await query(
`
select n.id, n.user_id as "userId", u.name as "userName",
u.phone as "userPhone", u.avatar_url as "userAvatarUrl",
u.phone as "userPhone", null::text as "userAvatarUrl",
n.notification_type as "notificationType", n.status, n.severity,
n.title, n.message, n.action_label as "actionLabel",
n.action_path as "actionPath", n.source_type as "sourceType",

View File

@@ -1870,7 +1870,7 @@ export async function badgeGrantsRoute(ctx: RequestContext) {
const items = await query(
`
select ub.id, ub.legacy_id as "legacyId", ub.user_id as "userId",
u.name as "userName", u.phone as "userPhone", u.avatar_url as "userAvatarUrl",
u.name as "userName", u.phone as "userPhone", null::text as "userAvatarUrl",
ub.badge_id as "badgeId", b.name as "badgeName", b.category,
b.icon_url as "iconUrl", b.level, ub.granted_by as "grantedBy",
gu.name as "grantedByName", ub.note, ub.metadata,