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

@@ -14,7 +14,7 @@
- `apps/api` 独立业务 API后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、男女默认预设头像、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜接口(租户默认关闭)、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。学生头像不支持上传或第三方头像落库,学生激励默认以勋章自动发放为主,不默认启用排行榜。
- 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目同步会保护租户自改题目返回冲突而不覆盖后台可查询冲突明细worker 失败会生成租户 `public_question_bank_sync_failed` 通知,恢复成功自动关闭失败通知,平台可用 `/api/platform-admin/question-bank-sync-status``platform:question_bank:ops` 查看跨租户同步运营摘要。
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets``daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。

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,

View File

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

View File

@@ -51,6 +51,7 @@
- 严禁绕过 `content_assets` 台账直接拼接私有 OSS/COS/Supabase Storage URL。
- 严禁在日志、导入 issue、审计日志中写入明文验证码、商户密钥、OAuth secret、支付私钥。
- 严禁把旧 PocketBase 字段结构作为新系统长期事实来源。
- 严禁为学生头像新增上传链路、对象存储签名、第三方头像落库或后台批量导入字段;学生头像只允许 `avatarPreset=male/female` 默认资源,租户后台学生接口也不能写头像 URL 或平台主角色字段。
## Supabase 直连表的准入条件

View File

@@ -74,5 +74,6 @@ types.ts 仅本领域使用的类型
- 平台公共题库不能被租户前端直接跨租户读取;平台侧通过 `/api/platform-admin/question-bank-grants` 授权,租户侧通过 `/api/tenant-content/public-question-banks/adopt` 采纳为本租户题库、入口、集合和题目快照。后续版本同步必须走 worker 和审计。
- `learning` 创建练习 session 时必须保存 `question_ids` 快照,避免随机刷题和模考过程中题目集合变化导致答题记录无法复盘。
- 排行榜必须由后端按租户、地区、班级和可信用户上下文聚合,并受 `tenant_settings.feature_flags.enableLeaderboard` 控制,默认关闭;前端不能自行扫描答题记录、积分流水或单词进度后排名,也不能在个人中心默认请求排行榜;后续高流量场景再通过 worker/materialized view 做日榜、周榜和防刷。
- 学生头像是产品级安全边界:学生端、租户后台学生运营、批量导入、批量分班都不能写 `avatarUrl/avatar_url/avatar/headimgurl/figureurl` 等字段,也不能通过学生接口写 `primaryRole/primary_role`;学生展示头像统一来自 `student_profiles.avatar_preset` 的男女默认头像。
- 资料、PDF、视频等对象存储资源必须先进入 `content_assets` 台账,再通过 API/Edge Function 做权限校验和签名 URL 下发;前端不能直接拼 OSS/COS/Supabase Storage 私有地址。
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录题目、单词、知识手册、分数线和视频 JSON/CSV/Excel 都应进入同一管线,复杂大批量导入通过 `executionMode=async` 交给 imports worker 消费。

View File

@@ -64,9 +64,9 @@
| 模考交卷报告 | 可联调 | `POST /api/learning/practice-sessions/submit``GET /api/learning/practice-sessions/report``GET /api/learning/practice-reports`;后端按 session 快照评分、分段统计、错题解析汇总,复合题返回 `questionResults[].subResults` 和部分得分,重复提交幂等 |
| 学习历史/统计/趋势 | 可联调 | `GET /api/learning/practice-sessions/history``GET /api/learning/stats``GET /api/learning/trend`;可支撑个人中心、练习历史、正确率趋势和题型分布 |
| 错题复习计划 | 可联调 | `GET /api/learning/wrong-questions/review-plan` + `POST /api/learning/practice-sessions``mode=wrong_review`,后端从本人错题本安全组卷 |
| 学习排行榜 | 可联调 | `GET /api/learning/leaderboard`;租户 `feature_flags.enableLeaderboard` 默认关闭,开启后支持 `questions``score``vocabulary``mock_exam` 四类指标,支持 `all``7d``30d` 周期和租户/地区/班级范围,返回当前用户排名并拒绝跨租户 session当前产品默认不在学生端请求排行榜日常激励以后台配置勋章自动发放为主 |
| 学习排行榜 | 可联调 | `GET /api/learning/leaderboard`;租户 `feature_flags.enableLeaderboard` 默认关闭,开启后支持 `questions``score``vocabulary``mock_exam` 四类指标,支持 `all``7d``30d` 周期和租户/地区/班级范围,返回当前用户排名并拒绝跨租户 session返回头像只使用 `avatarPreset` 默认头像路径,不暴露第三方头像 URL当前产品默认不在学生端请求排行榜,日常激励以后台配置勋章自动发放为主 |
| 考试倒计时 | 可联调 | `GET /api/catalog/exam-dates``GET /api/profile/exam-countdowns`;返回租户/地区匹配考试日期和 `daysLeft` |
| 学生预设头像 | 可联调 | `GET/PATCH /api/profile/me`;仅支持 `avatarPreset=male/female` 和系统默认头像路径,后端拒绝直接 `PATCH avatarUrl`,不提供用户头像上传;微信/QQ 登录PocketBase 导入脚本均不会把第三方头像 URL 写入学生资料 |
| 学生预设头像 | 可联调 | `GET/PATCH /api/profile/me`;仅支持 `avatarPreset=male/female` 和系统默认头像路径,后端拒绝直接 `PATCH avatarUrl`,不提供用户头像上传;微信/QQ 登录PocketBase 导入脚本、租户后台学生 upsert/批量导入/批量分班均不会把第三方头像 URL 写入学生资料,学生写入口带头像字段会返回或逐行记录 `STUDENT_AVATAR_URL_UNSUPPORTED` |
| 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转,处理结果和奖励积分会写入用户站内通知;`GET /api/tenant-admin/feedbacks/report` 已提供 7/30/90 天只读运营聚合,包含状态/类型/分类/优先级分布、处理效率、奖励积分、高频题目和待处理积压 |
| 签到积分 | 可联调 | `POST /api/profile/check-in``GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分;租户可用 `taskType=daily_check_in` 积分任务配置基础/连续签到奖励,后端自动写积分流水和领取记录;真实签到成功会触发 `check_in``score` 规则勋章自动发放,重复签到不重复发放 |
| 积分活动/兑换 | 可联调 | `GET /api/profile/activity-tasks``POST /api/profile/activity-tasks/claim``GET /api/profile/exchange-items``POST /api/profile/exchange-items/redeem``GET /api/tenant-admin/points-risk-report`;复用 `user_score_events` 积分账本,任务领取和兑换均事务加锁;手动/练习/单词/模考类任务有后端证据校验,反馈解决等系统任务禁止学生自领;兑换支持库存、个人限购、余额校验、幂等 key、优惠券履约、兑换订单和完成/待履约站内通知;租户后台积分风控报表按 7/30/90 天只读聚合异常用户、大额积分事件、高频任务和大额兑换,不直接改账、封禁或冻结权益 |
@@ -144,7 +144,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/tenant-admin/students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups`;支持逐行结果、限量、防跨租户和教师范围校验;学生导入/upsert/分班禁止 `avatarUrl/avatar_url/avatar/headimgurl/figureurl``primaryRole/primary_role`,避免绕过预设头像与租户角色体系 |
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
| 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、平台员工列表、平台员工创建/编辑、平台员工禁用/恢复、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量平台 API 已拆分 `platform:staff:read/write/status``platform:tenant:read/write/status/billing_profile``platform:billing:read/write/payment/dunning/notification``platform:audit:read/export/alert/notification``platform:question_bank:read/grant/ops` 等权限点;审计导出、平台员工操作、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook发送成功会推进提醒状态失败会退避重试联系方式和请求 payload 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 |
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |

View File

@@ -10,7 +10,7 @@
- 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费、逾期催缴和用量。
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、男女预设头像、站内通知、勋章、排行榜接口(租户默认关闭)、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API签到、积分阈值、反馈解决和积分活动可触发自动勋章发放反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知;租户后台已具备反馈运营聚合报表和积分风控只读报表。
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、男女预设头像、站内通知、勋章、排行榜接口(租户默认关闭)、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API学生头像不支持上传或第三方头像落库,学生写入口会拒绝头像 URL签到、积分阈值、反馈解决和积分活动可触发自动勋章发放,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知;租户后台已具备反馈运营聚合报表和积分风控只读报表。
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
@@ -65,7 +65,7 @@
5. 个人中心
-`GET /api/profile/me`
- 接会员权益、订单详情/状态轮询、优惠券、激活码预检查/兑换、错题本、收藏夹、学习统计、男女预设头像、勋章、积分活动任务、积分兑换和站内通知;排行榜只在租户显式开启后进入独立页面或活动页。
- 接会员权益、订单详情/状态轮询、优惠券、激活码预检查/兑换、错题本、收藏夹、学习统计、男女预设头像、勋章、积分活动任务、积分兑换和站内通知;学生头像只展示 `avatarPreset` 默认资源,不接上传或微信/QQ 头像;排行榜只在租户显式开启后进入独立页面或活动页。
6. 资料、视频和支付
- 资料下载、PDF 预览、视频播放必须先请求后端签名或权限检查。

View File

@@ -20,7 +20,7 @@
- 内容导入:题目、单词、知识手册、分数线、视频 JSON/CSV/Excel 预览、校验、导入、幂等、审计。
- 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;学生写入口已拒绝头像 URL、第三方头像字段和 `primaryRole`,学生头像统一走男女预设;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、反馈运营聚合报表、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。学生激励以后台配置勋章自动发放为主,不默认启用排行榜竞争。后续补更细活动效果看板和更细前端运营配置体验。
- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单兑换完成或待履约会写入用户站内通知Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台、反馈运营摘要和积分风控只读摘要第一版。后续补更细活动效果看板。
@@ -159,7 +159,7 @@
- 继续补真实打款 provider、发票管理、批量凭证上传、销售团队分佣看板和异常调整单。
12. 学生运营管理
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务;学生写入口拒绝头像 URL、第三方头像字段和 `primaryRole`,避免绕过预设头像与租户角色体系
- 已完成 Taro 学生运营页第一版:学生创建/更新、状态禁用/恢复、批量导入、批量分班、备注、跟进任务和完成跟进。
- 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和更细导入模板体验。

View File

@@ -754,7 +754,7 @@ POST /api/tenant-admin/badge-grants
| 学习概览卡片 | `GET /api/learning/stats?days=30` | 返回总答题、正确率、报告数、错题数、收藏数、题型分布 |
| 正确率趋势图 | `GET /api/learning/trend?days=14` | 返回每日答题数、正确数、错题数、session 数、报告数 |
| 错题复习入口 | `GET /api/learning/wrong-questions/review-plan?limit=20` | 返回建议复习题和后端组卷 nextAction |
| 排行榜 | `GET /api/learning/leaderboard?metric=questions&period=7d&regionId=...&classId=...` | 仅租户 `features.enableLeaderboard=true` 时请求;返回排名、用户展示信息、当前用户排名范围信息 |
| 排行榜 | `GET /api/learning/leaderboard?metric=questions&period=7d&regionId=...&classId=...` | 仅租户 `features.enableLeaderboard=true` 时请求;返回排名、用户展示信息、当前用户排名范围信息`avatarPreset` 默认头像路径 |
错题复习创建 session
@@ -786,7 +786,7 @@ POST /api/tenant-admin/badge-grants
| `classId` | UUID | 班级范围,可选,后端按当前租户校验 |
| `limit` / `page` | 正整数 | 分页 |
响应会包含 `items``currentUser`。即使当前用户未进入前 N 名,也应优先展示 `currentUser` 作为“我的排名”。后台后续会补日/周榜预聚合和防刷策略,前端只消费接口返回口径。
响应会包含 `items``currentUser`。即使当前用户未进入前 N 名,也应优先展示 `currentUser` 作为“我的排名”。排行榜用户头像只返回系统默认头像路径和 `avatarPreset`,前端不能尝试展示微信/QQ 头像或旧库头像 URL。后台后续会补日/周榜预聚合和防刷策略,前端只消费接口返回口径。
### 租户数据看板
@@ -1125,7 +1125,7 @@ GET /api/tenant-content/assets/security-scan-events?assetId=<assetId>&limit=100
## 个人中心预设头像
学生端不支持用户上传头像。系统只提供男女两种默认头像预设,避免头像上传带来的存储、内容安全、隐私和审核复杂度。前端不要展示文件选择器,不要调用对象存储上传链路,也不要把微信/QQ 返回的头像 URL 写入学生资料。后端登录链路和 PocketBase 导入脚本会丢弃第三方头像字段,学生展示头像必须来自 `avatarPreset`
学生端不支持用户上传头像。系统只提供男女两种默认头像预设,避免头像上传带来的存储、内容安全、隐私和审核复杂度。前端不要展示文件选择器,不要调用对象存储上传链路,也不要把微信/QQ 返回的头像 URL 写入学生资料。后端登录链路和 PocketBase 导入脚本会丢弃第三方头像字段;租户后台学生创建、更新、批量导入和批量分班也会拒绝 `avatarUrl/avatar_url/avatar/headimgurl/figureurl` 等字段,学生展示头像必须来自 `avatarPreset`
当前 Taro service 使用:
@@ -1156,6 +1156,7 @@ updateProfile({ avatarPreset: 'male' | 'female' })
- 允许用户在 UI 中二选一切换 `male` / `female`,调用 `PATCH /api/profile/me``avatarPreset` 字段保存。
- `PATCH /api/profile/me``avatarUrl` 会返回 `PROFILE_AVATAR_URL_DIRECT_UPDATE_REJECTED`,前端不应提供该输入。
- `avatarPreset` 只能是 `male``female`;其它值会返回 `INVALID_AVATAR_PRESET`
- `PUT /api/tenant-admin/students``POST /api/tenant-admin/students/bulk-upsert``POST /api/tenant-admin/classes/members/bulk-assign` 针对学生数据传头像字段会返回或逐行记录 `STUDENT_AVATAR_URL_UNSUPPORTED`;传 `primaryRole/primary_role` 会返回或逐行记录 `STUDENT_PRIMARY_ROLE_UNSUPPORTED`
- 默认头像资源由前端静态资产或 CDN 提供,路径保持 `/assets/avatars/default-male.svg``/assets/avatars/default-female.svg`
签到入口调用:
@@ -1475,7 +1476,7 @@ 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`,前端必须展示逐行错误,不要在浏览器端静默丢弃失败行。
- 学生批量导入和批量分班接口会返回 `total/successCount/errorCount/results`,前端必须展示逐行错误,不要在浏览器端静默丢弃失败行。学生导入模板不要提供头像 URL 或 `primaryRole` 字段;后端会拒绝这类字段,学生头像统一由个人中心 `avatarPreset` 控制。
- 教师可以为范围内学生创建备注和跟进任务,但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定;前端只按返回值渲染。
- H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。

View File

@@ -9145,15 +9145,62 @@ async function testTenantStudentOperations() {
name: 'Integration Bulk Missing Region',
regionId: '00000000-0000-0000-0000-ffffffffffff',
},
{
username: 'integration_bulk_student_avatar',
phone: '13800000020',
name: 'Integration Bulk Student Avatar',
regionId: ids.region,
avatarUrl: 'https://example.test/not-allowed-student-avatar.png',
},
],
},
});
assert.equal(bulk.total, 2, 'bulk student upsert should report total');
assert.equal(bulk.total, 3, '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');
assert.equal(bulk.errorCount, 2, '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');
assert.ok(
bulk.errors?.some(item => item.code === 'STUDENT_AVATAR_URL_UNSUPPORTED'),
'bulk student upsert should reject row-level avatar URLs',
);
const singleAvatarDenied = await request('/api/tenant-admin/students', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
username: 'integration_avatar_denied',
phone: '13800000021',
name: 'Integration Avatar Denied',
regionId: ids.region,
avatarUrl: 'https://example.test/direct-student-avatar.png',
},
expectStatus: 400,
});
assert.equal(
singleAvatarDenied.code,
'STUDENT_AVATAR_URL_UNSUPPORTED',
'tenant student upsert must reject direct avatar URLs',
);
const primaryRoleDenied = await request('/api/tenant-admin/students', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
username: 'integration_primary_role_denied',
phone: '13800000022',
name: 'Integration Primary Role Denied',
regionId: ids.region,
primaryRole: 'platform_admin',
},
expectStatus: 400,
});
assert.equal(
primaryRoleDenied.code,
'STUDENT_PRIMARY_ROLE_UNSUPPORTED',
'tenant student upsert must not allow platform primary role changes',
);
const bulkLimitDenied = await request('/api/tenant-admin/students/bulk-upsert', {
userId: TENANT_ADMIN_USER_ID,