forked from wangziqi/gongxue-base
feat: use preset avatars and disable leaderboard by default
This commit is contained in:
@@ -76,6 +76,21 @@ function metricUnit(metric: LeaderboardMetric) {
|
||||
}[metric];
|
||||
}
|
||||
|
||||
async function assertLeaderboardEnabled(tenantId: string) {
|
||||
const settings = await queryOne<{ enabled: boolean }>(
|
||||
`
|
||||
select coalesce(feature_flags ->> 'enableLeaderboard', 'false') = 'true' as enabled
|
||||
from public.tenant_settings
|
||||
where tenant_id = $1
|
||||
limit 1
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
if (!settings?.enabled) {
|
||||
throw new HttpError(403, 'Leaderboard is disabled for this tenant', 'LEADERBOARD_DISABLED');
|
||||
}
|
||||
}
|
||||
|
||||
function rowToItem(row: RankedRow, currentUserId: string) {
|
||||
return {
|
||||
rank: Number(row.rank),
|
||||
@@ -270,6 +285,8 @@ function buildMetricCte(metric: LeaderboardMetric, period: LeaderboardPeriod, te
|
||||
export async function learningLeaderboardRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
await assertLeaderboardEnabled(tenantId);
|
||||
|
||||
const metric = metricFrom(stringParam(ctx, 'metric'));
|
||||
const period = periodFrom(stringParam(ctx, 'period'));
|
||||
const limit = intParam(ctx, 'limit', 25, 100);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { autoGrantBadges } from './badges.js';
|
||||
|
||||
type JsonMap = Record<string, unknown>;
|
||||
type AvatarPreset = 'male' | 'female';
|
||||
|
||||
interface ProfileRow {
|
||||
id: string;
|
||||
@@ -12,7 +13,7 @@ interface ProfileRow {
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
avatarPreset: AvatarPreset;
|
||||
primaryRole: string;
|
||||
score: number;
|
||||
regionId: string | null;
|
||||
@@ -74,6 +75,20 @@ function daysBetween(dateValue: unknown, today: Date) {
|
||||
|
||||
const FEEDBACK_TYPES = ['question_error', 'content_error', 'video_error', 'asset_error', 'system_bug', 'suggestion', 'other'];
|
||||
const BADGE_CATEGORIES = ['learning', 'practice', 'vocabulary', 'mock_exam', 'activity', 'feedback', 'sales', 'system', 'custom'];
|
||||
const AVATAR_PRESETS = ['male', 'female'];
|
||||
|
||||
function avatarPresetFrom(value: unknown): AvatarPreset | null {
|
||||
if (value === undefined) return null;
|
||||
const preset = nullableString(value);
|
||||
if (!preset || !AVATAR_PRESETS.includes(preset)) {
|
||||
throw new HttpError(400, 'avatarPreset must be male or female', 'INVALID_AVATAR_PRESET');
|
||||
}
|
||||
return preset as AvatarPreset;
|
||||
}
|
||||
|
||||
function avatarDisplayUrl(preset: AvatarPreset) {
|
||||
return `/assets/avatars/default-${preset}.svg`;
|
||||
}
|
||||
|
||||
export async function profileMeRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
@@ -83,7 +98,8 @@ export async function profileMeRoute(ctx: RequestContext) {
|
||||
const profile = await queryOne<ProfileRow>(
|
||||
`
|
||||
select sp.id, u.id as "userId", u.username, u.phone, u.email::text, u.name,
|
||||
u.avatar_url as "avatarUrl", u.primary_role as "primaryRole", u.score,
|
||||
coalesce(sp.avatar_preset, 'male') as "avatarPreset",
|
||||
u.primary_role as "primaryRole", u.score,
|
||||
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",
|
||||
@@ -206,6 +222,10 @@ export async function profileMeRoute(ctx: RequestContext) {
|
||||
isSvip: !!entitlement,
|
||||
entitlement,
|
||||
},
|
||||
avatar: {
|
||||
preset: profile.avatarPreset,
|
||||
displayUrl: avatarDisplayUrl(profile.avatarPreset),
|
||||
},
|
||||
stats: {
|
||||
...profile.stats,
|
||||
answers: {
|
||||
@@ -238,7 +258,14 @@ export async function updateProfileMeRoute(ctx: RequestContext) {
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
|
||||
const name = optionalString(body, 'name') || null;
|
||||
const avatarUrl = optionalString(body, 'avatarUrl') || null;
|
||||
if (Object.hasOwn(body, 'avatarUrl')) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'avatarUrl cannot be updated directly. Use avatarPreset.',
|
||||
'PROFILE_AVATAR_URL_DIRECT_UPDATE_REJECTED',
|
||||
);
|
||||
}
|
||||
const avatarPreset = avatarPresetFrom(body.avatarPreset);
|
||||
const regionId = optionalString(body, 'regionId') || null;
|
||||
const selectedSchoolId = optionalString(body, 'selectedSchoolId') || null;
|
||||
const selectedMajorId = optionalString(body, 'selectedMajorId') || null;
|
||||
@@ -248,26 +275,26 @@ export async function updateProfileMeRoute(ctx: RequestContext) {
|
||||
with updated_user as (
|
||||
update public.platform_users
|
||||
set name = coalesce($3, name),
|
||||
avatar_url = coalesce($4, avatar_url),
|
||||
updated_at = now()
|
||||
where id = $2
|
||||
returning id
|
||||
)
|
||||
insert into public.student_profiles (
|
||||
tenant_id, user_id, region_id, selected_school_id, selected_major_id,
|
||||
tenant_id, user_id, avatar_preset, region_id, selected_school_id, selected_major_id,
|
||||
stats, progress, module_selections, recent_activities
|
||||
)
|
||||
values ($1, $2, $5::uuid, $6::uuid, $7::uuid, $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb)
|
||||
values ($1, $2, coalesce($4, 'male'), $5::uuid, $6::uuid, $7::uuid, $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb)
|
||||
on conflict (tenant_id, user_id)
|
||||
do update set region_id = coalesce(excluded.region_id, public.student_profiles.region_id),
|
||||
do update set avatar_preset = coalesce($15, public.student_profiles.avatar_preset),
|
||||
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 $12::boolean then excluded.stats else public.student_profiles.stats end,
|
||||
progress = case when $13::boolean then excluded.progress else public.student_profiles.progress end,
|
||||
module_selections = case when $14::boolean then excluded.module_selections else public.student_profiles.module_selections end,
|
||||
recent_activities = case when $15::boolean then excluded.recent_activities else public.student_profiles.recent_activities end,
|
||||
recent_activities = case when $16::boolean then excluded.recent_activities else public.student_profiles.recent_activities end,
|
||||
updated_at = now()
|
||||
returning tenant_id as "tenantId", user_id as "userId", region_id as "regionId",
|
||||
returning tenant_id as "tenantId", user_id as "userId", avatar_preset as "avatarPreset", region_id as "regionId",
|
||||
selected_school_id as "selectedSchoolId", selected_major_id as "selectedMajorId",
|
||||
stats, progress, module_selections as "moduleSelections",
|
||||
recent_activities as "recentActivities", updated_at as "updatedAt"
|
||||
@@ -276,7 +303,7 @@ export async function updateProfileMeRoute(ctx: RequestContext) {
|
||||
tenantId,
|
||||
userId,
|
||||
name,
|
||||
avatarUrl,
|
||||
avatarPreset,
|
||||
regionId,
|
||||
selectedSchoolId,
|
||||
selectedMajorId,
|
||||
@@ -287,6 +314,7 @@ export async function updateProfileMeRoute(ctx: RequestContext) {
|
||||
Object.hasOwn(body, 'stats'),
|
||||
Object.hasOwn(body, 'progress'),
|
||||
Object.hasOwn(body, 'moduleSelections'),
|
||||
avatarPreset,
|
||||
Object.hasOwn(body, 'recentActivities'),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,11 +4,9 @@ import { Button, Input, Text, View } from '@tarojs/components';
|
||||
import { logout } from '@/services/auth';
|
||||
import { checkActivationCode, loadEntitlements, loadOrders, loadSvipPlans, redeemActivationCode, type OrderItem, type SvipPlan } from '@/services/commerce';
|
||||
import {
|
||||
loadLeaderboard,
|
||||
loadLearningStats,
|
||||
loadLearningTrend,
|
||||
loadPracticeHistory,
|
||||
type LeaderboardItem,
|
||||
type LearningStats,
|
||||
type LearningTrendItem,
|
||||
type PracticeHistoryItem,
|
||||
@@ -144,8 +142,6 @@ export default function StudentProfilePage() {
|
||||
const [orders, setOrders] = useState<OrderItem[]>([]);
|
||||
const [badges, setBadges] = useState<Record<string, unknown>[]>([]);
|
||||
const [countdowns, setCountdowns] = useState<Record<string, unknown>[]>([]);
|
||||
const [leaderboard, setLeaderboard] = useState<LeaderboardItem[]>([]);
|
||||
const [currentRank, setCurrentRank] = useState<LeaderboardItem | null>(null);
|
||||
const [learningStats, setLearningStats] = useState<LearningStats | null>(null);
|
||||
const [learningTrend, setLearningTrend] = useState<LearningTrendItem[]>([]);
|
||||
const [practiceHistory, setPracticeHistory] = useState<PracticeHistoryItem[]>([]);
|
||||
@@ -168,7 +164,6 @@ export default function StudentProfilePage() {
|
||||
loadOrders().catch(() => ({ items: [] })),
|
||||
loadBadges().catch(() => ({ items: [] })),
|
||||
loadExamCountdowns().catch(() => ({ items: [] })),
|
||||
loadLeaderboard('questions').catch(() => ({ items: [], currentUser: null })),
|
||||
loadLearningStats(30).catch(() => ({ item: undefined })),
|
||||
loadLearningTrend(14).catch(() => ({ items: [] })),
|
||||
loadPracticeHistory({ limit: 6 }).catch(() => ({ items: [] })),
|
||||
@@ -183,7 +178,6 @@ export default function StudentProfilePage() {
|
||||
orderPayload,
|
||||
badgePayload,
|
||||
countdownPayload,
|
||||
leaderboardPayload,
|
||||
statsPayload,
|
||||
trendPayload,
|
||||
historyPayload,
|
||||
@@ -197,8 +191,6 @@ export default function StudentProfilePage() {
|
||||
setOrders(orderPayload.items || []);
|
||||
setBadges(badgePayload.items || []);
|
||||
setCountdowns(countdownPayload.items || []);
|
||||
setLeaderboard(leaderboardPayload.items || []);
|
||||
setCurrentRank(leaderboardPayload.currentUser || null);
|
||||
setLearningStats(statsPayload.item || null);
|
||||
setLearningTrend(trendPayload.items || []);
|
||||
setPracticeHistory(historyPayload.items || []);
|
||||
@@ -621,7 +613,7 @@ export default function StudentProfilePage() {
|
||||
</View>
|
||||
<View className='grid-two'>
|
||||
<View className='metric'><Text className='metric-value'>{String(badges.length)}</Text><Text className='metric-label'>勋章</Text></View>
|
||||
<View className='metric'><Text className='metric-value'>{currentRank ? `第${String(currentRank.rank)}` : String(leaderboard.length)}</Text><Text className='metric-label'>{currentRank ? `7 日答题榜 · ${String(currentRank.value)} 题` : '排行榜样本'}</Text></View>
|
||||
<View className='metric'><Text className='metric-value'>{String(wrongBook?.unresolvedWrong ?? 0)}</Text><Text className='metric-label'>待解决错题</Text></View>
|
||||
<View className='metric'><Text className='metric-value'>{String(favoriteStats?.favoriteQuestions ?? 0)}</Text><Text className='metric-label'>收藏题目</Text></View>
|
||||
<View className='metric'><Text className='metric-value'>{percent(reportStats?.avgAccuracy)}</Text><Text className='metric-label'>报告平均正确率</Text></View>
|
||||
</View>
|
||||
|
||||
@@ -4,6 +4,11 @@ export interface StudentProfile {
|
||||
userId?: string;
|
||||
name?: string | null;
|
||||
phone?: string | null;
|
||||
avatarPreset?: 'male' | 'female';
|
||||
avatar?: {
|
||||
preset?: 'male' | 'female';
|
||||
displayUrl?: string | null;
|
||||
};
|
||||
score?: number;
|
||||
target?: {
|
||||
regionId?: string | null;
|
||||
@@ -89,7 +94,7 @@ export async function loadProfile() {
|
||||
|
||||
export async function updateProfile(input: {
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
avatarPreset?: 'male' | 'female';
|
||||
regionId?: string;
|
||||
selectedSchoolId?: string;
|
||||
selectedMajorId?: string;
|
||||
|
||||
@@ -17,7 +17,7 @@ apps/api/src/
|
||||
health/ 健康检查
|
||||
tenant/ 租户解析、品牌配置、域名识别
|
||||
catalog/ 公开题库、内容入口、分类树、题目集合、练习蓝图、手册、商城、资料资源只读接口
|
||||
learning/ 组卷 session、答题、错题、收藏、练习进度、排行榜
|
||||
learning/ 组卷 session、答题、错题、收藏、练习进度、排行榜(租户默认关闭)
|
||||
commerce/ 订单、支付确认、退款、激活码、优惠券、权益
|
||||
referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列
|
||||
storage/ 对象存储签名 provider
|
||||
@@ -34,7 +34,7 @@ apps/api/src/
|
||||
```text
|
||||
features/
|
||||
auth/ 登录、绑定手机、OAuth 回调、会话换取
|
||||
learning/ 顺序/随机/模考组卷、答题记录、错题、收藏、学习进度、排行榜
|
||||
learning/ 顺序/随机/模考组卷、答题记录、错题、收藏、学习进度、排行榜(租户默认关闭)
|
||||
commerce/ 商品、订单、优惠券、支付、退款、权益开通
|
||||
referral/ 销售/代理增长链路、客资归属、分佣依据、CRM 入队
|
||||
platform-admin/ 平台租户管理、年费、服务费、批量开票、账务审计
|
||||
@@ -73,6 +73,6 @@ types.ts 仅本领域使用的类型
|
||||
- 题库前端入口不再只依赖旧 `module_nodes/subjects/categories`;新业务主模型是 `content_entries/content_nodes/question_collections/practice_blueprints`,用于表达可视化入口、多级分类、考试意向标记、题目列表和顺序/随机/全真模拟规则。
|
||||
- 平台公共题库不能被租户前端直接跨租户读取;平台侧通过 `/api/platform-admin/question-bank-grants` 授权,租户侧通过 `/api/tenant-content/public-question-banks/adopt` 采纳为本租户题库、入口、集合和题目快照。后续版本同步必须走 worker 和审计。
|
||||
- `learning` 创建练习 session 时必须保存 `question_ids` 快照,避免随机刷题和模考过程中题目集合变化导致答题记录无法复盘。
|
||||
- 排行榜必须由后端按租户、地区、班级和可信用户上下文聚合,前端不能自行扫描答题记录、积分流水或单词进度后排名;后续高流量场景再通过 worker/materialized view 做日榜、周榜和防刷。
|
||||
- 排行榜必须由后端按租户、地区、班级和可信用户上下文聚合,并受 `tenant_settings.feature_flags.enableLeaderboard` 控制,默认关闭;前端不能自行扫描答题记录、积分流水或单词进度后排名,也不能在个人中心默认请求排行榜;后续高流量场景再通过 worker/materialized view 做日榜、周榜和防刷。
|
||||
- 资料、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 消费。
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
| RLS/租户隔离 | 可联调 | 表层普遍有 `tenant_id` 和 RLS 策略;API 已支持 `tk_` 迁移 session 与 Supabase Auth JWT 双入口,并覆盖跨租户/伪造身份集成测试;生产前继续补真实云端 JWT/RLS 回归 |
|
||||
| API 分层 | 可联调 | `apps/api/src/core` + `apps/api/src/features/*` |
|
||||
| Docker API | 可联调 | `docker-compose.api.yml` 和 `apps/api/Dockerfile` 可用 |
|
||||
| 测试 | 可联调 | `npm run check:refactor` 覆盖 TS 检查、导入校验、生产 readiness 脚本测试、seed、API 集成测试;排行榜已覆盖四类指标、班级范围和跨租户拒绝 |
|
||||
| 测试 | 可联调 | `npm run check:refactor` 覆盖 TS 检查、导入校验、生产 readiness 脚本测试、seed、API 集成测试;排行榜已覆盖默认关闭、四类指标、班级范围和跨租户拒绝 |
|
||||
| 根 workspace | 可联调 | 根目录已清理为新技术栈 monorepo 编排层 |
|
||||
|
||||
## 租户与品牌
|
||||
@@ -64,8 +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`;支持 `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 |
|
||||
| 考试倒计时 | 可联调 | `GET /api/catalog/exam-dates`、`GET /api/profile/exam-countdowns`;返回租户/地区匹配考试日期和 `daysLeft` |
|
||||
| 学生预设头像 | 可联调 | `GET/PATCH /api/profile/me`;仅支持 `avatarPreset=male/female` 和系统默认头像路径,后端拒绝直接 `PATCH avatarUrl`,不提供用户头像上传 |
|
||||
| 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转,处理结果和奖励积分会写入用户站内通知 |
|
||||
| 签到积分 | 可联调 | `POST /api/profile/check-in`、`GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分;真实签到成功会触发 `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`;复用 `user_score_events` 积分账本,任务领取和兑换均事务加锁;手动/练习/单词/模考类任务有后端证据校验,反馈解决等系统任务禁止学生自领;兑换支持库存、个人限购、余额校验、幂等 key、优惠券履约、兑换订单和完成/待履约站内通知 |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
- 平台侧可以管理租户、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费、逾期催缴和用量。
|
||||
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
|
||||
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、站内通知、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。
|
||||
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、男女预设头像、站内通知、勋章、排行榜接口(租户默认关闭)、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。
|
||||
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。
|
||||
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 |
|
||||
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台账号细粒度权限目录、平台员工列表/创建/编辑/启停、平台路由权限强校验、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量、公共题库授权、公共题库地区/科目授权校验、SaaS 套餐/订阅 metadata 公共题库访问边界、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台在线收款、平台审计告警升级策略和更完整运营消息 |
|
||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、starter 单地区/专业版全国公共题库访问边界、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
|
||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜接口(租户默认关闭)、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、starter 单地区/专业版全国公共题库访问边界、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜开启前压测、防刷和预聚合 |
|
||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜接口(租户默认关闭) | 更细复习参数 |
|
||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入,AI 择校推荐已可读取地区/分数线上下文 | 复杂筛选和更细 AI 推荐运营配置 |
|
||||
| 视频解析 | 可联调 | 单题视频、批量查询、后台视频绑定、JSON/CSV/Excel 导入、会员播放权限、播放次数扣减、签名 URL、播放日志和动态水印上下文 | 深度防盗链、转码级水印、播放统计 |
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
5. 个人中心
|
||||
- 调 `GET /api/profile/me`。
|
||||
- 接会员权益、订单详情/状态轮询、优惠券、激活码预检查/兑换、错题本、收藏夹、学习统计、排行榜、勋章、积分活动任务、积分兑换和站内通知。
|
||||
- 接会员权益、订单详情/状态轮询、优惠券、激活码预检查/兑换、错题本、收藏夹、学习统计、男女预设头像、勋章、积分活动任务、积分兑换和站内通知;排行榜只在租户显式开启后进入独立页面或活动页。
|
||||
|
||||
6. 资料、视频和支付
|
||||
- 资料下载、PDF 预览、视频播放必须先请求后端签名或权限检查。
|
||||
@@ -108,7 +108,7 @@
|
||||
- `npm run test:rls` 在本地通过,且新增租户表的 RLS 动态用例已补齐或有明确豁免说明。
|
||||
- `npm run smoke:auth:remote` 在预生产/生产 API 上通过,真实 Supabase JWT 能映射学生、租户管理员和平台管理员,坏 token/错租户上下文被拒绝。
|
||||
- 使用真实 PocketBase 导出数据完成一次 production dry-run,产出问题清单和修复后的二次导入报告。
|
||||
- 核心学生链路 API 可以在 Taro H5 和小程序双端跑通:登录、首页、题库、练习、错题、收藏、单词、手册、会员、排行榜、个人中心。
|
||||
- 核心学生链路 API 可以在 Taro H5 和小程序双端跑通:登录、首页、题库、练习、错题、收藏、单词、手册、会员、个人中心;排行榜按租户开关可选接入,默认不在学生端请求。
|
||||
- 租户隔离、后台角色权限、资源访问权限、订单权益和内容导入至少有集成测试覆盖。
|
||||
|
||||
后端进入“商用生产交付”的最低标准:
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
- API 已按 `core/features` 分层:
|
||||
- `auth`:短信验证码登录、迁移期 session、微信小程序登录、OAuth provider 预留。
|
||||
- `catalog`:公开题库、地区、内容入口、分类树、题目集合、练习蓝图、考试日期、手册、商品、SVIP 套餐、资料资源只读/下载接口。
|
||||
- `learning`:顺序/随机/全真模拟组卷 session、答题记录、错题、收藏、背单词进度/收藏/统计、排行榜。
|
||||
- `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈、勋章、站内通知。
|
||||
- `learning`:顺序/随机/全真模拟组卷 session、答题记录、错题、收藏、背单词进度/收藏/统计、排行榜接口(租户默认关闭)。
|
||||
- `profile`:学生个人中心、男女预设头像、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈、勋章、站内通知。
|
||||
- `scoreline`:分数线字段、院校、专业、记录、趋势、年份。
|
||||
- `video`:题目视频讲解、批量预加载、通用视频搜索。
|
||||
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。
|
||||
@@ -24,7 +24,7 @@
|
||||
- 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||
- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
|
||||
- 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转;`user_score_events` 支持每日签到积分流水和反馈奖励幂等;`badges/user_badges` 支持租户后台勋章维护、手动发放、重复发放幂等和学生个人中心展示;`user_notifications` 支持反馈处理、反馈奖励、勋章发放和积分兑换的学生站内通知;Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。
|
||||
- 旧题库排行榜主链路已补齐:`GET /api/learning/leaderboard` 支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。
|
||||
- 旧题库排行榜主链路已补齐但租户默认关闭:`GET /api/learning/leaderboard` 只有在 `feature_flags.enableLeaderboard=true` 时可用,支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。
|
||||
- `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
|
||||
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
|
||||
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
|
||||
|
||||
@@ -84,9 +84,9 @@
|
||||
| 知识手册 | `apps/taro/src/pages/student/handbook/index.tsx` | `handbook-subjects`、`handbook-chapters`、`handbook-entries`;已接 RichContent 阅读渲染第一版 |
|
||||
| 分数线 | `apps/taro/src/pages/student/scoreline/index.tsx` | `scoreline/records` |
|
||||
| 资料 | `apps/taro/src/pages/student/assets/index.tsx` | `assets`、`assets/preview`、`assets/download`;已接短期签名、过期信息、可见水印覆盖、traceId 展示和强制水印资源外部预览限制 |
|
||||
| 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me`、`check-in`、`score-events`、`badges`、`exam-countdowns`、`svip-plans`、`orders`、`entitlements`、`activation-codes`、`leaderboard`、`learning/stats`、`learning/trend`、`practice-sessions/history`、`profile/activity-tasks`、`profile/activity-tasks/claim`、`profile/exchange-items`、`profile/exchange-items/redeem`、`profile/notifications`、`profile/notifications/status`;已接学习报告、练习趋势、最近练习、会员订单、激活码、积分任务/兑换/积分明细和消息中心第一版 |
|
||||
| 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me`、`check-in`、`score-events`、`badges`、`exam-countdowns`、`svip-plans`、`orders`、`entitlements`、`activation-codes`、`learning/stats`、`learning/trend`、`practice-sessions/history`、`profile/activity-tasks`、`profile/activity-tasks/claim`、`profile/exchange-items`、`profile/exchange-items/redeem`、`profile/notifications`、`profile/notifications/status`;已接学习报告、练习趋势、最近练习、会员订单、激活码、积分任务/兑换/积分明细和消息中心第一版 |
|
||||
|
||||
当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/复合题多小题/收藏/反馈/视频 -> 交卷报告逐题复盘 -> 错题/收藏复习”,背单词已经覆盖“单元 -> 今日计划/全单元/收藏练习 -> 卡片翻转 -> 发音 -> 认识/再记上报 -> 本地恢复”,资料页已经覆盖“列表 -> 申请预览/下载短签名 -> 展示水印 traceId -> H5 水印容器预览或确认下载”,个人中心已经覆盖“基础资料 -> 学习报告 -> 14 天趋势 -> 题型表现 -> 最近练习 -> 排名/勋章/签到 -> 会员/订单/激活码 -> 积分任务/兑换/明细 -> 消息筛选/已读/归档”,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。`apps/taro/src/components/RichContent.tsx` 是学生端题干、选项、解析和知识手册的统一安全渲染组件:它只支持受控 Markdown 图片、基础表格、粗体、代码和被 parser 识别出的公式 token;H5 端用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]`,私有题图可用 `asset:<uuid>`、`content_asset:<uuid>` 或 `/asset/<uuid>` 资源引用向后端申请短期预览签名。组件不执行导入内容中的任意 HTML/JS,也会拒绝 `javascript:`、`data:` 等危险图片地址。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补独立消息中心增强、小程序公式真机验收、题图资源字段化、小程序分享/支付容器体验。
|
||||
当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/复合题多小题/收藏/反馈/视频 -> 交卷报告逐题复盘 -> 错题/收藏复习”,背单词已经覆盖“单元 -> 今日计划/全单元/收藏练习 -> 卡片翻转 -> 发音 -> 认识/再记上报 -> 本地恢复”,资料页已经覆盖“列表 -> 申请预览/下载短签名 -> 展示水印 traceId -> H5 水印容器预览或确认下载”,个人中心已经覆盖“基础资料 -> 学习报告 -> 14 天趋势 -> 题型表现 -> 最近练习 -> 勋章/签到 -> 会员/订单/激活码 -> 积分任务/兑换/明细 -> 消息筛选/已读/归档”,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。`apps/taro/src/components/RichContent.tsx` 是学生端题干、选项、解析和知识手册的统一安全渲染组件:它只支持受控 Markdown 图片、基础表格、粗体、代码和被 parser 识别出的公式 token;H5 端用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]`,私有题图可用 `asset:<uuid>`、`content_asset:<uuid>` 或 `/asset/<uuid>` 资源引用向后端申请短期预览签名。组件不执行导入内容中的任意 HTML/JS,也会拒绝 `javascript:`、`data:` 等危险图片地址。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补独立消息中心增强、小程序公式真机验收、题图资源字段化、小程序分享/支付容器体验。
|
||||
|
||||
## 已落地的 Taro 租户后台页面
|
||||
|
||||
|
||||
@@ -28,12 +28,12 @@
|
||||
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
|
||||
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
|
||||
| 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、微信/支付宝发起退款、微信/支付宝退款查询确认、微信/支付宝退款通知 webhook、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询、支付/退款补偿、微信/支付宝官方账单下载、资金对账、差错工单、异常订单运营台和人工调整凭证已实现 | API 集成测试、commerce worker 集成测试 | 商城主链路可联调,Taro 租户财务运营台第一版已接;真实生产账单格式验收、生产账号联调和更完整学生端售后体验待补 |
|
||||
| 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、每日复习计划、租户后台单词维护 API、旧模板/新模板 JSON 预览导入、排行榜已实现 | 核心 API 集成测试含导入和排行榜断言,Taro 类型检查覆盖卡片学习和收藏练习 | 学生端学习状态、后台维护、批量 JSON 导入、Taro 卡片翻转、发音、美/英音切换、收藏练习、本地位置恢复和基础排行榜已实现;租户级发音源、收藏专项统计、更细复习参数和后台统计待完善 |
|
||||
| 背单词 | 已建单词单元、单词、进度、收藏表,并可绑定 `content_entries/content_nodes` | 已支持内容和部分用户状态映射 | 单元/单词只读、进度、收藏、统计、每日复习计划、租户后台单词维护 API、旧模板/新模板 JSON 预览导入、排行榜接口已实现且租户默认关闭 | 核心 API 集成测试含导入和排行榜断言,Taro 类型检查覆盖卡片学习和收藏练习 | 学生端学习状态、后台维护、批量 JSON 导入、Taro 卡片翻转、发音、美/英音切换、收藏练习、本地位置恢复和基础排行榜接口已实现且默认关闭;租户级发音源、收藏专项统计、更细复习参数和后台统计待完善 |
|
||||
| 知识手册 | 已建手册科目、章节、条目,并可绑定 `content_entries/content_nodes` | 已支持内容导入 | 只读 API、租户后台手册科目/章节/条目维护 API、嵌套 JSON 预览导入已实现 | 核心 API 集成测试含导入断言,Taro 类型检查含 RichContent 阅读页 | 学生端阅读、RichContent 安全渲染、后台维护和批量 JSON 导入基础可用,搜索、高亮、目录锚点、真正公式渲染、富文本资源/版本管理待补 |
|
||||
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 |
|
||||
| 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、播放水印上下文、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放、水印 traceId 和导入断言 | 播放、权益、后台绑定、动态水印上下文和批量 JSON 导入链路已实现,深度防盗链、转码级水印和播放统计待补 |
|
||||
| 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、资源访问审计、动态水印上下文、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 自动发布可信资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载、安全扫描门禁、水印 traceId,assets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closed,exports worker 测试;Taro 类型检查覆盖学生资料水印预览/下载确认 | 资料资源基础闭环可跑,Taro 学生资料页已接短期签名、可见水印覆盖、追踪码展示和强制水印资源外部打开限制第一版;真实 AV/内容安全服务联调、CDN 防盗链、转码/CDN 级水印待补 |
|
||||
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges`、积分任务/兑换表、`user_notifications` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、积分任务、积分兑换、题目反馈、考试倒计时、勋章和站内通知 API 已实现 | API 集成测试、Taro 类型检查 | 学生端个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细、消息中心、激活码和订单入口第一版;头像上传、账号合并、更细学习建议和独立消息中心增强待补 |
|
||||
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges`、积分任务/兑换表、`user_notifications` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、积分任务、积分兑换、题目反馈、考试倒计时、勋章和站内通知 API 已实现 | API 集成测试、Taro 类型检查 | 学生端个人中心已接学习报告、14 天趋势、题型表现、最近练习、积分任务/兑换/积分明细、消息中心、激活码和订单入口第一版;预设头像 UI、账号合并、更细学习建议和独立消息中心增强待补 |
|
||||
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 |
|
||||
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
||||
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
| 知识手册 | `Handbook*.tsx` | 已覆盖 | Taro 已接手册科目/章节/条目、RichContent 安全渲染、H5 KaTeX 公式渲染和私有资源 ID 短签名地基第一版;后续补搜索、高亮、目录锚点、小程序公式真机验收和图片资源字段化 |
|
||||
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 |
|
||||
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、微信/支付宝官方账单下载 worker、异常查询、差错工单和事件轨迹、异常订单运营台、人工调整凭证复核报表已有;Taro 学生收银台/订单详情和租户财务运营台第一版已接;仍缺更完整售后体验、真实生产账单抽样验收和生产账号联调 |
|
||||
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、积分活动任务、积分兑换、考试倒计时、趋势、勋章展示和站内通知 API 已有;Taro 个人中心已接学习报告、积分任务、积分兑换、积分明细、消息筛选/已读/归档第一版;继续补头像上传、账号合并和更细学习建议 |
|
||||
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、男女预设头像、权益、订单统计、练习历史、学习统计、签到积分、积分活动任务、积分兑换、考试倒计时、趋势、勋章展示和站内通知 API 已有;Taro 个人中心已接学习报告、积分任务、积分兑换、积分明细、消息筛选/已读/归档第一版;继续补账号合并、更细学习建议和预设头像 UI 接入 |
|
||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、Taro 学生端水印 traceId 预览/下载确认、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;仍缺深度防盗链、真实 AV/内容安全服务联调、转码/CDN 级水印和生命周期策略 |
|
||||
| AI 择校推荐 | 业务规划新增 | 部分覆盖 | 已有 SVIP 门禁、学生输入 schema、地区/分数线上下文、稳定 JSON 输出、报告台账、审计和 Taro 学生端基础页;真实 AI provider、prompt 版本管理后台、报告 PDF 渲染和更细推荐算法待补 |
|
||||
| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分和站内通知已覆盖;Taro 个人中心消息中心可查看反馈处理/奖励通知;仍缺外部订阅消息/短信提醒、批量统计和内容修复闭环 |
|
||||
@@ -97,8 +97,8 @@
|
||||
这些是旧项目中已经出现过、但新后端还没有完整业务闭环的功能:
|
||||
|
||||
1. 阅读理解/案例分析多小题:后端 `subAnswers` 判分、断点恢复、报告 `subResults`、Taro 作答 UI、RichContent 长题干/图片/H5 KaTeX 公式渲染、私有资源 ID 短签名题图和逐题复盘第一版已完成;仍需补小程序公式真机验收、题图资源字段化和更细复盘交互。
|
||||
2. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有;还需防刷、日/周榜预聚合、运营后台排名看板。
|
||||
3. 账号设置完整流:绑定/更换手机号基础 API 已完成;仍缺头像上传、微信/QQ 账号合并、密码/邮箱能力。
|
||||
2. 排行榜增强:刷题、模考、背单词、积分排行榜主接口已有,租户默认关闭;还需防刷、日/周榜预聚合、运营后台排名看板和开启前压测。
|
||||
3. 账号设置完整流:绑定/更换手机号基础 API 和预设头像契约已完成;仍缺微信/QQ 账号合并、密码/邮箱能力和预设头像页面 UI 接入。
|
||||
4. 题库导出:服务端 JSON/试卷 payload、PDF/Word 二进制生成、水印、资料发布路径、权限审计、答案脱敏、每日一练基础导出和图片 ZIP 素材包已补;仍缺更精细试卷模板和后台导出操作台体验。
|
||||
5. 导入扩展:题目/单词/知识手册/分数线/视频已支持 JSON、CSV 和 Excel 预览导入,并可用 `executionMode=async` 进入 imports worker;导入后复检、导入任务详情、模板下载按钮、字段映射 API、Taro 字段别名编辑、异步轮询和 PocketBase JSON dry-run 报告已补,仍缺真实数据执行验收。
|
||||
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
- Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。
|
||||
- Node.js API 分层:`core/features`。
|
||||
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。
|
||||
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜(租户默认关闭)、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、男女预设头像、考试倒计时、签到积分、题目反馈、勋章、站内通知。
|
||||
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
||||
- 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。
|
||||
- 平台后台 API/worker:租户、租户详情、账务资料维护、平台员工列表/创建/编辑/启停、平台审计日志查询/导出、平台审计告警规则/列表/确认/解决、审计告警外部通知渠道/事件、platform-audit-alerts worker、platform-audit-notifications worker、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件、platform-dunning-notifications worker、用量。
|
||||
@@ -21,7 +21,7 @@
|
||||
- 内容导出:题库 JSON、`paper_json`、打印 payload 导出基础已完成,支持集合/入口/分类范围、导出 job、审计、跨租户拒绝、答案/解析开关和复合题子题脱敏。
|
||||
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。
|
||||
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。
|
||||
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。
|
||||
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决和积分活动任务自动发放第一阶段已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。后续补练习次数、单词掌握、模考成绩触发和前端运营配置体验。
|
||||
- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;兑换完成或待履约会写入用户站内通知;Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台第一版。后续补连续签到奖励配置、积分风控报表和更细活动效果看板。
|
||||
- 用户站内通知第一版已完成:反馈处理、反馈奖励、勋章发放、积分兑换会创建 `user_notifications`;学生端可查询/标记状态,租户后台具备 `notifications:read` 权限的成员可查看租户内通知;Taro 学生个人中心已接消息筛选、已读和归档第一版,租户营销中心已接用户通知查看和筛选第一版。后续补独立消息中心增强、外部微信订阅消息/短信和批量统计。
|
||||
@@ -135,7 +135,7 @@
|
||||
7. 学习统计
|
||||
- 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。
|
||||
- 已完成单词复习算法、每日计划和复习上报。
|
||||
- 已完成排行榜主接口;继续补防刷、日/周榜预聚合和运营后台排名看板。
|
||||
- 已完成排行榜主接口并通过租户开关默认关闭;继续补防刷、日/周榜预聚合、运营后台排名看板和开启前压测。
|
||||
- Taro 已有后端 session detail 续练、本地断点恢复、倒计时和阅读理解/案例分析多小题第一版;继续补复盘体验、长题干/公式图片混排和更细统计口径。
|
||||
|
||||
8. 订单和营销体验
|
||||
@@ -201,7 +201,7 @@
|
||||
- 旧原生小程序前端位于 `F:\project\参考\旧题库小程序前端文件`,后续 Taro 小程序/H5 交互补齐时只作为交互、页面状态和微信平台能力参考,不继承旧直连 PocketBase 或旧鉴权逻辑。
|
||||
- 租户通过域名、小程序配置或启动参数解析。
|
||||
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台员工列表/创建/编辑/禁用恢复、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、预设头像契约、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台员工列表/创建/编辑/禁用恢复、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补预设头像 UI、小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
||||
|
||||
### 第一批页面
|
||||
|
||||
@@ -239,7 +239,8 @@
|
||||
|
||||
8. 个人中心
|
||||
- 会员权益、订单、激活码兑换
|
||||
- 错题本、收藏夹、学习统计、排行榜、勋章、站内通知
|
||||
- 错题本、收藏夹、学习统计、勋章、站内通知,排行榜仅在租户开启后展示
|
||||
- 男女预设头像契约已接,页面 UI 待接入;不做用户头像上传
|
||||
- Taro 已接学习报告、14 天趋势、题型表现、最近练习和 7 日答题榜当前排名第一版
|
||||
- 会员收银台、优惠券领取、下单、支付参数、订单详情、状态轮询和售后入口已接第一版
|
||||
|
||||
@@ -253,7 +254,7 @@
|
||||
## 推荐下一步顺序
|
||||
|
||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、预设头像契约、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补预设头像 UI、小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。
|
||||
3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 使用 `F:\project\参考\旧题库数据库文件` 中的真实 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 继续做人工复核和抽样验收;当前 dry-run/导入/校验链路已跑通,下一步重点是 7 个已支付缺用户订单、22 个待复核手册章节、2533 道旧分类缺失题目和 5298 条引用已删除题目的答题记录的运营处理结论。导入过程中发现的字段污染、跨集合引用断裂、敏感字段和旧权限问题都要沉淀到 importer mapper 或修复脚本,不手工临时修库。
|
||||
|
||||
@@ -61,6 +61,7 @@ npm run perf:api:local
|
||||
| `PERF_RAMP_SECONDS` | `3` | 并发爬坡时间 |
|
||||
| `PERF_QUESTION_LIMIT` | `20` | 单次合集题目请求数量 |
|
||||
| `PERF_INCLUDE_WRITES` | `false` | 是否加入创建练习 session 写请求 |
|
||||
| `PERF_INCLUDE_LEADERBOARD` | `false` | 是否加入排行榜接口;排行榜租户默认关闭,仅在租户明确开启并需要专项压测时打开 |
|
||||
| `PERF_OUTPUT_DIR` | `docs/refactor/performance-reports` | 报告输出目录 |
|
||||
| `PERF_REQUEST_TIMEOUT_MS` | `15000` | 单请求超时 |
|
||||
|
||||
@@ -78,12 +79,18 @@ npm run perf:api:local
|
||||
- `/api/catalog/practice-blueprints`
|
||||
- `/api/learning/stats`
|
||||
- `/api/learning/trend`
|
||||
- `/api/learning/leaderboard`
|
||||
- `/api/catalog/vocabulary-words`
|
||||
- `/api/learning/vocabulary/stats`
|
||||
- `/api/catalog/handbook-chapters`
|
||||
- `/api/catalog/handbook-entries?includeContent=true`
|
||||
|
||||
排行榜租户默认关闭,不进入默认压测负载。只有在租户已经开启 `feature_flags.enableLeaderboard=true`,并准备验证独立排行榜页或活动页容量时,才打开:
|
||||
|
||||
```powershell
|
||||
$env:PERF_INCLUDE_LEADERBOARD="true"
|
||||
npm run perf:api:local
|
||||
```
|
||||
|
||||
写入路径默认关闭。只有在专门的测试库或可丢弃预生产库中,才建议打开:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -253,7 +253,7 @@ tenant:<tenantId>:theme
|
||||
| 错题复习 | `GET /api/learning/wrong-questions/review-plan`、`POST /api/learning/practice-sessions` with `mode=wrong_review` |
|
||||
| 收藏夹 | `GET/POST /api/learning/favorites/questions` |
|
||||
| 练习历史/统计 | `GET /api/learning/practice-sessions/history`、`GET /api/learning/stats`、`GET /api/learning/trend` |
|
||||
| 学习排行榜 | `GET /api/learning/leaderboard?metric=questions&period=all` |
|
||||
| 学习排行榜 | `GET /api/learning/leaderboard?metric=questions&period=all`,仅当租户 `features.enableLeaderboard=true` 时启用 |
|
||||
| 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch`、`POST /api/videos/play` |
|
||||
| 题目反馈 | `POST /api/profile/feedbacks`、`GET /api/profile/feedbacks` |
|
||||
| 分佣结算 | `GET /api/commission/settings`、`PUT /api/commission/settings`、`PUT /api/commission/member-rate`、`GET /api/commission/summary`、`GET /api/commission/orders`、`GET /api/commission/settlements`、`POST /api/commission/settlements/generate`、`POST /api/commission/settlements/status` |
|
||||
@@ -745,7 +745,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®ionId=...&classId=...` | 返回排名、用户展示信息、当前用户排名和范围信息 |
|
||||
| 排行榜 | `GET /api/learning/leaderboard?metric=questions&period=7d®ionId=...&classId=...` | 仅租户 `features.enableLeaderboard=true` 时请求;返回排名、用户展示信息、当前用户排名和范围信息 |
|
||||
|
||||
错题复习创建 session:
|
||||
|
||||
@@ -765,7 +765,7 @@ POST /api/tenant-admin/badge-grants
|
||||
|
||||
### 排行榜
|
||||
|
||||
排行榜由后端统一聚合,前端不要读取答题记录、单词进度或模考报告后自行排名,避免越权、口径漂移和跨租户数据泄露。
|
||||
排行榜由后端统一聚合,但当前产品默认关闭,租户 `feature_flags.enableLeaderboard` 未开启时接口返回 `LEADERBOARD_DISABLED`。前端默认不要在个人中心请求或展示排行榜;只有租户明确开启并完成压测后,再进入独立排行榜页或活动页。前端不要读取答题记录、单词进度或模考报告后自行排名,避免越权、口径漂移和跨租户数据泄露。
|
||||
|
||||
可选参数:
|
||||
|
||||
@@ -1114,6 +1114,41 @@ GET /api/tenant-content/assets/security-scan-events?assetId=<assetId>&limit=100
|
||||
|
||||
首页可用 `GET /api/catalog/exam-dates?regionId=<regionId>` 展示地区公开考试日期;个人中心优先用 `GET /api/profile/exam-countdowns`,后端会按学生当前 `regionId/selectedSchoolId` 返回匹配倒计时。
|
||||
|
||||
## 个人中心预设头像
|
||||
|
||||
学生端不支持用户上传头像。系统只提供男女两种默认头像预设,避免头像上传带来的存储、内容安全、隐私和审核复杂度。前端不要展示文件选择器,不要调用对象存储上传链路,也不要把微信/QQ 返回的头像 URL 写入学生资料。
|
||||
|
||||
当前 Taro service 使用:
|
||||
|
||||
```text
|
||||
apps/taro/src/services/profile.ts
|
||||
|
||||
loadProfile
|
||||
updateProfile({ avatarPreset: 'male' | 'female' })
|
||||
```
|
||||
|
||||
`GET /api/profile/me` 会返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"item": {
|
||||
"avatarPreset": "male",
|
||||
"avatar": {
|
||||
"preset": "male",
|
||||
"displayUrl": "/assets/avatars/default-male.svg"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
前端展示规则:
|
||||
|
||||
- 直接使用 `item.avatar.displayUrl` 展示头像。
|
||||
- 允许用户在 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`。
|
||||
- 默认头像资源由前端静态资产或 CDN 提供,路径保持 `/assets/avatars/default-male.svg` 和 `/assets/avatars/default-female.svg`。
|
||||
|
||||
签到入口调用:
|
||||
|
||||
```http
|
||||
|
||||
@@ -221,6 +221,29 @@ async function latestVideoPlayEventByToken(playToken) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setTenantFeatureFlag(tenantId, flag, enabled) {
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||||
try {
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
|
||||
values ($1, jsonb_build_object($2::text, $3::boolean), '{}'::jsonb, '{}'::jsonb)
|
||||
on conflict (tenant_id)
|
||||
do update set feature_flags = jsonb_set(
|
||||
coalesce(public.tenant_settings.feature_flags, '{}'::jsonb),
|
||||
array[$2::text],
|
||||
to_jsonb($3::boolean),
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
`,
|
||||
[tenantId, flag, enabled],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
@@ -2711,6 +2734,39 @@ async function testProfile() {
|
||||
assert.equal(payload.item?.userId, USER_ID, 'profile should belong to smoke user');
|
||||
assert.ok(payload.item?.stats?.vocabulary?.totalWords >= 1, 'profile should include vocabulary stats');
|
||||
assert.ok(Array.isArray(payload.item?.recentPractices), 'profile should include recent practices');
|
||||
assert.equal(payload.item?.avatarPreset, 'male', 'profile should default to the male preset avatar');
|
||||
assert.equal(payload.item?.avatar?.preset, 'male', 'profile avatar object should expose preset');
|
||||
assert.ok(payload.item?.avatar?.displayUrl?.includes('/assets/avatars/default-male.svg'), 'profile should expose the default male avatar asset path');
|
||||
|
||||
const directAvatarRejected = await request('/api/profile/me', {
|
||||
method: 'PATCH',
|
||||
body: { avatarUrl: 'https://example.test/not-allowed-avatar.png' },
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(
|
||||
directAvatarRejected.code,
|
||||
'PROFILE_AVATAR_URL_DIRECT_UPDATE_REJECTED',
|
||||
'students must not set arbitrary avatar URLs directly',
|
||||
);
|
||||
|
||||
const invalidAvatarPatch = await request('/api/profile/me', {
|
||||
method: 'PATCH',
|
||||
body: { avatarPreset: 'custom' },
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(invalidAvatarPatch.code, 'INVALID_AVATAR_PRESET', 'profile must reject unknown avatar presets');
|
||||
|
||||
const avatarPresetUpdated = await request('/api/profile/me', {
|
||||
method: 'PATCH',
|
||||
body: { avatarPreset: 'female' },
|
||||
});
|
||||
assert.equal(avatarPresetUpdated.item?.avatarPreset, 'female', 'student should update preset avatar choice');
|
||||
const profileAfterAvatarPreset = await request('/api/profile/me');
|
||||
assert.equal(profileAfterAvatarPreset.item?.avatar?.preset, 'female', 'profile should expose updated preset avatar');
|
||||
assert.ok(
|
||||
profileAfterAvatarPreset.item?.avatar?.displayUrl?.includes('/assets/avatars/default-female.svg'),
|
||||
'profile should expose the default female avatar asset path',
|
||||
);
|
||||
|
||||
const countdowns = await request('/api/profile/exam-countdowns');
|
||||
const profileExamDate = countdowns.items?.find(item => item.id === ids.examDate);
|
||||
@@ -2865,6 +2921,14 @@ async function testProfile() {
|
||||
}
|
||||
|
||||
async function testLearningLeaderboard() {
|
||||
const disabled = await request('/api/learning/leaderboard', {
|
||||
query: { metric: 'questions', period: 'all', limit: 10 },
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(disabled.code, 'LEADERBOARD_DISABLED', 'leaderboard should be disabled by default for tenants');
|
||||
|
||||
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', true);
|
||||
|
||||
const questions = await request('/api/learning/leaderboard', {
|
||||
query: { metric: 'questions', period: 'all', limit: 10 },
|
||||
});
|
||||
@@ -2911,6 +2975,13 @@ async function testLearningLeaderboard() {
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(crossTenantDenied.code, 'AUTH_TENANT_MISMATCH', 'leaderboard must reject trusted session cross-tenant access');
|
||||
|
||||
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', false);
|
||||
const disabledAfterRestore = await request('/api/learning/leaderboard', {
|
||||
query: { metric: 'questions', period: 'all', limit: 10 },
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(disabledAfterRestore.code, 'LEADERBOARD_DISABLED', 'leaderboard feature flag should be restorable after tests');
|
||||
}
|
||||
|
||||
async function testScoreline() {
|
||||
|
||||
@@ -14,6 +14,7 @@ const DURATION_SECONDS = envNumber('PERF_DURATION_SECONDS', 15);
|
||||
const CONCURRENCY = envNumber('PERF_CONCURRENCY', 6);
|
||||
const RAMP_SECONDS = envNumber('PERF_RAMP_SECONDS', 3, { allowZero: true });
|
||||
const INCLUDE_WRITES = envBool('PERF_INCLUDE_WRITES', false);
|
||||
const INCLUDE_LEADERBOARD = envBool('PERF_INCLUDE_LEADERBOARD', false);
|
||||
const START_PORT = Number(process.env.PERF_API_PORT || 0) || 0;
|
||||
const TENANT_CODE = process.env.PERF_TENANT_CODE || 'master';
|
||||
const QUESTION_LIMIT = envNumber('PERF_QUESTION_LIMIT', 20);
|
||||
@@ -381,8 +382,16 @@ function createEndpointCatalog(context) {
|
||||
{ name: 'catalog.content_entries', weight: 10, method: 'GET', path: '/api/catalog/content-entries', query: { entryType: 'question_practice' } },
|
||||
{ name: 'learning.stats', weight: 5, method: 'GET', path: '/api/learning/stats' },
|
||||
{ name: 'learning.trend', weight: 4, method: 'GET', path: '/api/learning/trend', query: { days: 14 } },
|
||||
{ name: 'leaderboard.questions_7d', weight: 3, method: 'GET', path: '/api/learning/leaderboard', query: { metric: 'questions', period: '7d', limit: 20 } },
|
||||
];
|
||||
if (INCLUDE_LEADERBOARD) {
|
||||
endpoints.push({
|
||||
name: 'leaderboard.questions_7d',
|
||||
weight: 3,
|
||||
method: 'GET',
|
||||
path: '/api/learning/leaderboard',
|
||||
query: { metric: 'questions', period: '7d', limit: 20 },
|
||||
});
|
||||
}
|
||||
|
||||
if (context.entry) {
|
||||
endpoints.push(
|
||||
|
||||
10
supabase/migrations/202606300009_profile_avatar_presets.sql
Normal file
10
supabase/migrations/202606300009_profile_avatar_presets.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
alter table public.student_profiles
|
||||
add column if not exists avatar_preset text not null default 'male';
|
||||
|
||||
alter table public.student_profiles
|
||||
drop constraint if exists student_profiles_avatar_preset_check,
|
||||
add constraint student_profiles_avatar_preset_check
|
||||
check (avatar_preset in ('male', 'female'));
|
||||
|
||||
comment on column public.student_profiles.avatar_preset is
|
||||
'Student profile preset avatar choice. User-uploaded avatars are intentionally unsupported.';
|
||||
@@ -30,7 +30,7 @@ on conflict (tenant_id) do nothing;
|
||||
insert into public.tenant_settings (tenant_id, feature_flags, admin_feature_flags, public_config)
|
||||
values (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'{"enableStore":true,"enableLeaderboard":true,"enableVocabulary":true,"enableHandbook":true,"enableScoreline":true}'::jsonb,
|
||||
'{"enableStore":true,"enableLeaderboard":false,"enableVocabulary":true,"enableHandbook":true,"enableScoreline":true}'::jsonb,
|
||||
'{"enableTenantManagement":true,"enableQuestionCRUD":true,"enableRevenueLedger":true,"enableMarketing":true}'::jsonb,
|
||||
'{"examDate":"","appUrl":"http://127.0.0.1:5173"}'::jsonb
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user