forked from wangziqi/gongxue-base
feat: enforce student avatar presets
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -229,6 +229,7 @@ export interface LeaderboardItem {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string | null;
|
||||
avatarPreset?: 'male' | 'female';
|
||||
primaryRole?: string;
|
||||
regionId?: string | null;
|
||||
regionName?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user