From 7d2b8fd6102830d2e911f2fadfb46fa99e778dd8 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 14:24:07 +0800 Subject: [PATCH] feat: auto grant badges for learning milestones --- apps/api/src/features/learning/routes.ts | 169 ++++++++++++++++++++- apps/api/src/features/profile/badges.ts | 35 ++++- docs/refactor/backend-capability-status.md | 4 +- docs/refactor/blueprint-coverage.md | 2 +- docs/refactor/frontend-handoff-index.md | 2 +- docs/refactor/implementation-status.md | 4 +- docs/refactor/legacy-feature-gap-matrix.md | 4 +- docs/refactor/next-development-todo.md | 6 +- docs/refactor/taro-frontend-integration.md | 11 +- scripts/api-integration-test.js | 91 +++++++++++ scripts/smoke-seed.js | 34 ++++- 11 files changed, 343 insertions(+), 19 deletions(-) diff --git a/apps/api/src/features/learning/routes.ts b/apps/api/src/features/learning/routes.ts index 63481bc5..8efc2500 100644 --- a/apps/api/src/features/learning/routes.ts +++ b/apps/api/src/features/learning/routes.ts @@ -13,6 +13,7 @@ import { } from '../../core/request.js'; import { query, queryOne, transaction } from '../../core/db.js'; import { assertAnswerSessionAccess, authorizePracticeSession, recordPracticeAccessEvent } from './access.js'; +import { autoGrantBadges, type AutoBadgeGrant } from '../profile/badges.js'; interface QuestionAnswerRow { question_id: string; @@ -93,6 +94,11 @@ interface PracticeSessionReportRow { updatedAt: string; } +interface PracticeSessionReportWithBadges { + report: PracticeSessionReportRow; + autoBadges: AutoBadgeGrant[]; +} + interface PracticeSessionReportAnswerRow { questionId: string; selectedOptions: unknown; @@ -616,6 +622,128 @@ function round4(value: number) { return Math.round(value * 10000) / 10000; } +async function totalPracticeReportCount(client: pg.PoolClient, tenantId: string, userId: string) { + const result = await client.query<{ count: string }>( + ` + select count(*)::text as count + from public.practice_session_reports + where tenant_id = $1 and user_id = $2 + `, + [tenantId, userId], + ); + return Number(result.rows[0]?.count || 0); +} + +async function bestMockExamScore(client: pg.PoolClient, tenantId: string, userId: string) { + const result = await client.query<{ score: string | number | null }>( + ` + select max(score) as score + from public.practice_session_reports + where tenant_id = $1 + and user_id = $2 + and mode = 'mock_exam' + `, + [tenantId, userId], + ); + return finiteNumber(result.rows[0]?.score, 0); +} + +async function masteredWordCount(client: pg.PoolClient, tenantId: string, userId: string) { + const result = await client.query<{ count: string }>( + ` + select count(*)::text as count + from public.user_word_progress + where tenant_id = $1 + and user_id = $2 + and status = 'mastered' + `, + [tenantId, userId], + ); + return Number(result.rows[0]?.count || 0); +} + +async function grantLearningBadgesForPracticeReport( + client: pg.PoolClient, + input: { + tenantId: string; + userId: string; + report: PracticeSessionReportRow; + }, +) { + const practiceCount = await totalPracticeReportCount(client, input.tenantId, input.userId); + const grants: AutoBadgeGrant[] = [ + ...(await autoGrantBadges(client, { + tenantId: input.tenantId, + userId: input.userId, + trigger: 'practice_count', + evidence: { + practiceCount, + practiceReportCount: practiceCount, + latestReportId: input.report.id, + latestPracticeSessionId: input.report.practiceSessionId, + latestPracticeMode: input.report.mode, + latestAnsweredCount: Number(input.report.answeredCount || 0), + latestCorrectCount: Number(input.report.correctCount || 0), + latestAccuracy: finiteNumber(input.report.accuracy, 0), + }, + })), + ]; + + if (input.report.mode === 'mock_exam') { + const mockExamScore = finiteNumber(input.report.score, 0); + const mockExamTotalScore = finiteNumber(input.report.totalScore, 0); + grants.push( + ...(await autoGrantBadges(client, { + tenantId: input.tenantId, + userId: input.userId, + trigger: 'mock_exam_score', + evidence: { + mockExamScore, + mockExamTotalScore, + mockExamAccuracy: finiteNumber(input.report.accuracy, 0), + mockExamBestScore: await bestMockExamScore(client, input.tenantId, input.userId), + practiceCount, + reportId: input.report.id, + practiceSessionId: input.report.practiceSessionId, + blueprintId: input.report.blueprintId, + collectionId: input.report.collectionId, + }, + })), + ); + } + + return grants; +} + +async function grantVocabularyMasteredBadges( + client: pg.PoolClient, + input: { + tenantId: string; + userId: string; + progress: Record; + source: string; + }, +) { + if (input.progress.status !== 'mastered') return []; + const count = await masteredWordCount(client, input.tenantId, input.userId); + return autoGrantBadges(client, { + tenantId: input.tenantId, + userId: input.userId, + trigger: 'vocabulary_mastered', + evidence: { + vocabularyMasteredCount: count, + masteredWordsCount: count, + progressId: input.progress.id, + wordId: input.progress.wordId, + correctCount: input.progress.correctCount, + wrongCount: input.progress.wrongCount, + reviewCount: input.progress.reviewCount, + correctStreak: input.progress.correctStreak, + source: input.source, + }, + }); +} + function dateParam(ctx: RequestContext, name: string) { const value = stringParam(ctx, name); if (!value) return ''; @@ -1547,8 +1675,18 @@ export async function submitPracticeSessionRoute(ctx: RequestContext) { const userId = await userIdFrom(ctx, body); const practiceSessionId = requiredString(body, 'practiceSessionId'); - const report = await transaction(async client => buildPracticeSessionReport(client, tenantId, userId, practiceSessionId)); - return { item: formatReport(report) }; + const result = await transaction(async client => { + const report = await buildPracticeSessionReport(client, tenantId, userId, practiceSessionId); + const autoBadges = await grantLearningBadgesForPracticeReport(client, { tenantId, userId, report }); + return { report, autoBadges }; + }); + + return { + item: { + ...formatReport(result.report), + ...(result.autoBadges.length ? { autoBadges: result.autoBadges } : {}), + }, + }; } export async function practiceSessionReportRoute(ctx: RequestContext) { @@ -2462,7 +2600,7 @@ export async function reviewWordRoute(ctx: RequestContext) { [tenantId, userId], ); - return { + const item = { ...progress.rows[0], review: { result, @@ -2470,6 +2608,18 @@ export async function reviewWordRoute(ctx: RequestContext) { nextReviewDate: schedule.nextReviewDate, }, }; + + const autoBadges = await grantVocabularyMasteredBadges(client, { + tenantId, + userId, + progress: item, + source: 'review_word', + }); + + return { + ...item, + ...(autoBadges.length ? { autoBadges } : {}), + }; }); return { item }; @@ -2541,7 +2691,18 @@ export async function updateWordProgressRoute(ctx: RequestContext) { [tenantId, userId], ); - return progress.rows[0]; + const item = progress.rows[0]; + const autoBadges = await grantVocabularyMasteredBadges(client, { + tenantId, + userId, + progress: item, + source: 'update_word_progress', + }); + + return { + ...item, + ...(autoBadges.length ? { autoBadges } : {}), + }; }); return { item }; diff --git a/apps/api/src/features/profile/badges.ts b/apps/api/src/features/profile/badges.ts index af0b18d6..2391da2a 100644 --- a/apps/api/src/features/profile/badges.ts +++ b/apps/api/src/features/profile/badges.ts @@ -3,7 +3,14 @@ import { createUserNotification } from '../notifications/service.js'; type JsonMap = Record; -export type BadgeTrigger = 'check_in' | 'score' | 'feedback_resolved' | 'activity_reward'; +export type BadgeTrigger = + | 'check_in' + | 'score' + | 'feedback_resolved' + | 'activity_reward' + | 'practice_count' + | 'vocabulary_mastered' + | 'mock_exam_score'; export interface BadgeMetricEvidence { checkInStreak?: number; @@ -11,6 +18,14 @@ export interface BadgeMetricEvidence { score?: number; feedbackResolvedCount?: number; rewardPoints?: number; + practiceCount?: number; + practiceReportCount?: number; + vocabularyMasteredCount?: number; + masteredWordsCount?: number; + mockExamScore?: number; + mockExamTotalScore?: number; + mockExamAccuracy?: number; + mockExamBestScore?: number; [key: string]: unknown; } @@ -57,6 +72,9 @@ const TRIGGER_UNLOCK_TYPES: Record = { score: ['score'], feedback_resolved: ['feedback_resolved'], activity_reward: ['activity_reward'], + practice_count: ['practice_count'], + vocabulary_mastered: ['vocabulary_mastered'], + mock_exam_score: ['mock_exam_score'], }; const FIELD_ALIASES: Record = { @@ -66,6 +84,12 @@ const FIELD_ALIASES: Record = { feedback_resolved: ['feedbackResolvedCount', 'feedback.resolvedCount', 'reports.resolvedCount'], reward_points: ['rewardPoints', 'feedback.rewardPoints'], activity_reward: ['activityRewardCount', 'activity.rewardCount', 'tasks.claimCount'], + practice_count: ['practiceCount', 'practice.count', 'practice.reportCount', 'practiceReportCount', 'reports.practiceCount'], + vocabulary_mastered: ['vocabularyMasteredCount', 'masteredWordsCount', 'vocabulary.masteredCount', 'words.masteredCount'], + mock_exam_score: ['mockExamScore', 'mockExam.score', 'report.score', 'score'], + mock_exam_total_score: ['mockExamTotalScore', 'mockExam.totalScore', 'report.totalScore'], + mock_exam_accuracy: ['mockExamAccuracy', 'mockExam.accuracy', 'report.accuracy'], + mock_exam_best_score: ['mockExamBestScore', 'mockExam.bestScore', 'reports.mockExamBestScore'], }; function numberValue(value: unknown) { @@ -88,6 +112,9 @@ function metricValueForField(field: string | null, evidence: BadgeMetricEvidence if (trigger === 'score') return numberValue(evidence.score); if (trigger === 'feedback_resolved') return numberValue(evidence.feedbackResolvedCount); if (trigger === 'activity_reward') return numberValue(evidence.activityRewardCount); + if (trigger === 'practice_count') return numberValue(evidence.practiceCount ?? evidence.practiceReportCount); + if (trigger === 'vocabulary_mastered') return numberValue(evidence.vocabularyMasteredCount ?? evidence.masteredWordsCount); + if (trigger === 'mock_exam_score') return numberValue(evidence.mockExamScore); return null; } @@ -99,6 +126,12 @@ function metricValueForField(field: string | null, evidence: BadgeMetricEvidence if (canonical === 'feedback_resolved') return numberValue(evidence.feedbackResolvedCount); if (canonical === 'reward_points') return numberValue(evidence.rewardPoints); if (canonical === 'activity_reward') return numberValue(evidence.activityRewardCount); + if (canonical === 'practice_count') return numberValue(evidence.practiceCount ?? evidence.practiceReportCount); + if (canonical === 'vocabulary_mastered') return numberValue(evidence.vocabularyMasteredCount ?? evidence.masteredWordsCount); + if (canonical === 'mock_exam_score') return numberValue(evidence.mockExamScore); + if (canonical === 'mock_exam_total_score') return numberValue(evidence.mockExamTotalScore); + if (canonical === 'mock_exam_accuracy') return numberValue(evidence.mockExamAccuracy); + if (canonical === 'mock_exam_best_score') return numberValue(evidence.mockExamBestScore); } } diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 6fb1fc76..13e82e5f 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -70,7 +70,7 @@ | 题目反馈/纠错 | 可联调 | `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、优惠券履约、兑换订单和完成/待履约站内通知 | -| 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态;签到连续天数、积分阈值、反馈解决和积分活动规则已支持自动发放,自动/手动获得勋章会写入用户站内通知 | +| 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态;签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩规则已支持自动发放,自动/手动获得勋章会写入用户站内通知 | | 学生站内通知 | 可联调 | `GET /api/profile/notifications`、`POST /api/profile/notifications/status`;学生只能查看和更新自己的通知,支持未读/已读/忽略/归档、类型筛选和状态汇总 | ## 背单词、知识手册、分数线、视频 @@ -138,7 +138,7 @@ | 密钥掩码/引用 | 迁移期 | API 有掩码,生产前要做 KMS/Vault 或 envelope encryption | | 活动、Banner、FAQ、公告 | 可联调 | `/api/tenant-admin/banners`、`faqs`、`announcements` | | 积分任务/兑换配置 | 可联调 | `/api/tenant-admin/point-activity-tasks`、`point-activity-claims`、`point-exchange-items`、`point-exchange-orders`;使用 `marketing:points:read/write` 或兼容 `marketing:read/write` 权限,支持任务配置、兑换商品配置、领取记录、兑换订单、审计、跨租户拒绝 | -| 勋章管理/发放 | 可联调 | `/api/tenant-admin/badges`、`/api/tenant-admin/badge-grants`;支持后台维护、同 `legacyId` 幂等更新、手动发放、重复发放幂等、租户隔离和权限点 `badges:read/write/grant`;`unlockType=check_in/score/feedback_resolved/activity_reward` 会由签到、积分奖励、反馈解决和活动任务事件自动发放,并写入用户站内通知 | +| 勋章管理/发放 | 可联调 | `/api/tenant-admin/badges`、`/api/tenant-admin/badge-grants`;支持后台维护、同 `legacyId` 幂等更新、手动发放、重复发放幂等、租户隔离和权限点 `badges:read/write/grant`;`unlockType=check_in/score/feedback_resolved/activity_reward/practice_count/vocabulary_mastered/mock_exam_score` 会由签到、积分奖励、反馈解决、活动任务、练习交卷、单词掌握和模考成绩事件自动发放,并写入用户站内通知 | | 考试日期维护 | 可联调 | `/api/tenant-admin/exam-dates`,支持地区维度维护和公开倒计时展示 | | 题目反馈处理 | 可联调 | `/api/tenant-admin/feedbacks`、`feedbacks/status`、`feedbacks/events`;支持状态流转、处理备注、审计事件、幂等奖励积分和用户站内通知 | | 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches`、`activation-codes` | diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index 23533b0a..1c8784b1 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -25,7 +25,7 @@ | 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 | | 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 | | 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、动态水印上下文、assets worker 复检、内置安全扫描和外部 HTTP scanner 接入层 | CDN 防盗链、真实 AV/内容安全服务联调、资料前端管理页 | -| 营销中心 | 可联调 | SVIP 套餐、激活码批次、激活码生成、优惠券启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细、核销报表、Banner/FAQ/公告、勋章管理、手动发放、签到/积分/反馈/活动任务自动发放、积分活动任务、积分兑换商品、兑换订单和优惠券兑换履约 | 连续签到奖励配置、练习/单词/模考触发勋章、积分风控报表、营销自动化和前端活动操作台 | +| 营销中心 | 可联调 | SVIP 套餐、激活码批次、激活码生成、优惠券启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细、核销报表、Banner/FAQ/公告、勋章管理、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分活动任务、积分兑换商品、兑换订单和优惠券兑换履约 | 连续签到奖励配置、积分风控报表、营销自动化和前端活动操作台 | | 销售/代理客资 | 可联调 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、团队关系、手动补绑、分佣比例、归因、结算单、审核和打款状态 | 真实微信小程序码、真实打款、结算导出、销售团队看板 | | CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 | | 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 077d96e3..ef00c271 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -56,7 +56,7 @@ - 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。 - 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描、外部 HTTP scanner 接入层和租户后台媒体运营报表;Taro 学生资料页已接短期签名、水印 traceId 展示和强制水印容器第一版;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。 - 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。 -- 数据看板、分佣结算、财务运营、勋章手动发放、签到/积分/反馈/积分活动自动发放、积分任务/兑换第一阶段、用户站内通知第一版和主题模板发布基础 API 已可联调;Taro 学生个人中心已接积分任务/兑换/积分明细第一版,租户营销中心已接积分任务/兑换配置和记录查看第一版;练习/单词/模考触发勋章、积分风控报表、连续签到奖励配置、外部微信订阅消息/短信、真实打款 provider、发票、真实生产账单抽样验收、AI 择校真实 provider、主题素材库/模板市场等仍是后续商用增强项。 +- 数据看板、分佣结算、财务运营、勋章手动发放、签到/积分/反馈/积分活动/练习/单词/模考自动发放、积分任务/兑换第一阶段、用户站内通知第一版和主题模板发布基础 API 已可联调;Taro 学生个人中心已接积分任务/兑换/积分明细第一版,租户营销中心已接积分任务/兑换配置和记录查看第一版;积分风控报表、连续签到奖励配置、外部微信订阅消息/短信、真实打款 provider、发票、真实生产账单抽样验收、AI 择校真实 provider、主题素材库/模板市场等仍是后续商用增强项。 ## 前后端协作建议 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index bb926689..58c42618 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -34,7 +34,7 @@ | 题目视频讲解 | 已建 `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 天趋势、题型表现、最近练习、积分任务/兑换/积分明细、消息中心、激活码和订单入口第一版;预设头像 UI、账号合并、更细学习建议和独立消息中心增强待补 | -| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 | +| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务/练习/单词/模考自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI | | 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件、平台权限字段和平台员工状态字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台员工列表/创建/编辑/启停、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台员工创建/权限目录/JWT 访问/越权拒绝/自降级拒绝/禁用后 JWT 拒绝/审计脱敏、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露;Taro 类型检查覆盖平台员工管理页面 | 平台收费、租户运营和员工授权链路骨架可用,平台在线收款和更完整平台审计报表待补 | @@ -313,5 +313,5 @@ platform-admin: 2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。 3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 4. 补视频商用控制:深度防盗链、转码级水印和播放统计。 -5. 补 AI 择校推荐报告、排行榜防刷/预聚合、连续签到奖励配置、积分风控报表、外部订阅消息/短信和更多学习行为触发勋章。 +5. 补 AI 择校推荐报告、排行榜防刷/预聚合、连续签到奖励配置、积分风控报表、外部订阅消息/短信和更完整活动效果看板。 6. 接真实短信/OAuth 生产账号、真实生产账单格式验收和异常订单运营台,并继续推进 Taro scaffold。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index c5800391..417f2d15 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -57,7 +57,7 @@ | SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI | | 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券未核销幂等、下单抵扣、全额优惠自动开通权益、最低金额、封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细和报表已有;前端营销活动 UI 继续完善 | | 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 | -| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示、权限隔离、签到连续天数、积分阈值、反馈解决、积分活动自动发放和站内通知已覆盖;练习次数、单词掌握、模考成绩触发和前端运营 UI 待补 | +| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示、权限隔离、签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握、模考成绩自动发放和站内通知已覆盖;前端运营 UI、连续签到奖励配置和积分风控报表待补 | | 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载、字段映射 API 和导入任务详情已有;Taro 租户内容页已接上传/粘贴预览、模板下载、字段别名编辑、同步/异步执行、异步轮询和复检结果详情第一版;真实数据验收待补 | | 题库导出 PDF/Word/JSON/每日一练 ZIP | 部分覆盖 | 服务端 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练 ZIP 图片素材包异步导出已补,含租户内容编辑权限、跨租户拒绝、答案/解析开关、子题脱敏、导出 job、审计、水印、`content_assets` 发布/下载路径和 exports worker;每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图包已补;后续补更精细试卷模板和后台操作台体验 | | 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 | @@ -106,7 +106,7 @@ 8. AI 择校推荐增强:后端 `local_rules` 地基、SVIP 门禁、报告列表/详情和 Taro 基础页已完成;仍缺真实 AI provider、prompt 编排、PDF 报告和后台运营配置。 9. 题目反馈增强:站内通知后端第一版已完成;仍缺前端消息中心、外部订阅消息/短信提醒、问题聚合统计和内容修复闭环。 10. 积分活动增强:积分兑换、活动任务、优惠券兑换履约和后台配置第一阶段已完成;仍缺连续签到奖励配置、积分风控报表、活动效果看板和更细系统任务触发。 -11. 勋章增强:后台维护、手动发放、签到/积分/反馈/活动任务自动发放和发放站内通知已有;仍缺按练习次数、单词掌握、模考成绩自动发放,以及前端运营配置体验。 +11. 勋章增强:后台维护、手动发放、签到/积分/反馈/活动任务/练习次数/单词掌握/模考成绩自动发放和发放站内通知已有;仍缺前端运营配置体验、连续签到奖励配置和积分风控报表。 ### P0:前端联调到云端前 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index d16076f6..f6588406 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -22,7 +22,7 @@ - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。 - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试但租户默认关闭。 -- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决和积分活动任务自动发放第一阶段已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。后续补练习次数、单词掌握、模考成绩触发和前端运营配置体验。 +- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决、积分活动任务、练习次数、单词掌握和模考成绩自动发放已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。后续补连续签到奖励配置、积分风控报表、活动效果看板和更细前端运营配置体验。 - 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;兑换完成或待履约会写入用户站内通知;Taro 学生个人中心已接积分任务、兑换商品和积分明细第一版,租户营销中心已接积分任务/兑换操作台第一版。后续补连续签到奖励配置、积分风控报表和更细活动效果看板。 - 用户站内通知第一版已完成:反馈处理、反馈奖励、勋章发放、积分兑换会创建 `user_notifications`;学生端可查询/标记状态,租户后台具备 `notifications:read` 权限的成员可查看租户内通知;Taro 学生个人中心已接消息筛选、已读和归档第一版,租户营销中心已接用户通知查看和筛选第一版。后续补独立消息中心增强、外部微信订阅消息/短信和批量统计。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 @@ -144,8 +144,8 @@ 9. 积分和反馈增强 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 - - 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决自动发放第一阶段。 - - 积分兑换、活动任务、优惠券兑换履约和站内通知后端第一阶段已完成;Taro 学生个人中心和租户营销中心已接积分任务/兑换第一版;继续补连续签到奖励配置、练习/单词/模考触发勋章、积分风控报表、独立消息中心增强、外部订阅消息/短信和反馈聚合统计。 + - 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决、积分活动、练习次数、单词掌握和模考成绩自动发放。 + - 积分兑换、活动任务、优惠券兑换履约和站内通知后端第一阶段已完成;Taro 学生个人中心和租户营销中心已接积分任务/兑换第一版;继续补连续签到奖励配置、积分风控报表、独立消息中心增强、外部订阅消息/短信和反馈聚合统计。 10. 数据看板 - 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 62052443..f9390ef1 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -659,8 +659,17 @@ POST /api/tenant-admin/badge-grants | `score` | `score` | 签到加分、反馈奖励或积分活动奖励成功后 | | `feedback_resolved` | `feedbackResolvedCount` | 租户后台把反馈处理为 `resolved` 后 | | `activity_reward` | `activityRewardCount` 或 `score` | 学生领取积分活动任务且后端证据校验通过后 | +| `practice_count` | `practiceCount` 或 `practiceReportCount` | `POST /api/learning/practice-sessions/submit` 生成练习报告后 | +| `vocabulary_mastered` | `vocabularyMasteredCount` 或 `masteredWordsCount` | `POST /api/learning/vocabulary/progress` 或 `POST /api/learning/vocabulary/review` 使单词状态达到 `mastered` 后 | +| `mock_exam_score` | `mockExamScore`、`mockExamAccuracy` 或 `mockExamBestScore` | `POST /api/learning/practice-sessions/submit` 提交 `mock_exam` 报告后 | -规则使用 `conditionOperator` 的合法值 `gte`、`gt`、`lte`、`lt`、`eq`;`conditionValue` 为数字。触发成功的接口会返回 `autoBadges`,前端可据此弹出“获得勋章”提示;如果是重复签到、重复处理反馈或已获得过同一勋章,后端不会重复返回同一发放记录。 +规则使用 `conditionOperator` 的合法值 `gte`、`gt`、`lte`、`lt`、`eq`;`conditionValue` 为数字。触发成功的接口会返回 `autoBadges`,前端可据此弹出“获得勋章”提示;如果是重复签到、重复处理反馈、重复交卷、重复单词复习或已获得过同一勋章,后端不会重复返回同一发放记录。 + +前端接入注意: + +- 刷题/模考交卷、单词进度更新、签到、反馈处理、积分任务领取都可能返回 `autoBadges`;统一交给一个成就提示组件处理。 +- 勋章是否已解锁仍以 `GET /api/profile/badges` 为准,`autoBadges` 只用于即时提示和刷新个人中心。 +- 租户后台配置自动勋章时,只展示上表中的 `unlockType` 和推荐字段,避免运营输入任意字段导致规则永远无法命中。 ### 模考交卷与报告 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 93718ca6..84f6f743 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -58,6 +58,9 @@ const ids = { tenantScoreBadge: '00000000-0000-0000-0000-000000000874', tenantFeedbackBadge: '00000000-0000-0000-0000-000000000875', tenantActivityBadge: '00000000-0000-0000-0000-000000000876', + tenantPracticeBadge: '00000000-0000-0000-0000-000000000881', + tenantVocabularyBadge: '00000000-0000-0000-0000-000000000882', + tenantMockExamBadge: '00000000-0000-0000-0000-000000000883', pointActivityTask: '00000000-0000-0000-0000-000000000877', pointSystemTask: '00000000-0000-0000-0000-000000000878', pointExchangeItem: '00000000-0000-0000-0000-000000000879', @@ -2386,6 +2389,50 @@ async function testCatalogAndLearning() { 'node session should include descendant questions', ); + const practiceBadge = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + id: ids.tenantPracticeBadge, + legacyId: 'integration-badge-practice-count-auto', + name: '集成测试练习完成勋章', + description: '完成一次练习报告后自动发放', + category: 'practice', + iconUrl: 'https://example.test/badges/practice-count-auto.png', + level: 1, + unlockType: 'practice_count', + conditionField: 'practiceCount', + conditionOperator: 'gte', + conditionValue: 1, + metadata: { source: 'integration-test', trigger: 'practice_count' }, + order: 3, + isActive: true, + }, + }); + assert.equal(practiceBadge.item?.id, ids.tenantPracticeBadge, 'tenant admin should create automatic practice count badge'); + + const mockExamBadge = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + id: ids.tenantMockExamBadge, + legacyId: 'integration-badge-mock-exam-score-auto', + name: '集成测试模考成绩勋章', + description: '模考分数达到规则后自动发放', + category: 'mock_exam', + iconUrl: 'https://example.test/badges/mock-exam-score-auto.png', + level: 1, + unlockType: 'mock_exam_score', + conditionField: 'mockExamScore', + conditionOperator: 'gte', + conditionValue: 4, + metadata: { source: 'integration-test', trigger: 'mock_exam_score' }, + order: 4, + isActive: true, + }, + }); + assert.equal(mockExamBadge.item?.id, ids.tenantMockExamBadge, 'tenant admin should create automatic mock exam score badge'); + const mockSession = await request('/api/learning/practice-sessions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', @@ -2456,6 +2503,14 @@ async function testCatalogAndLearning() { mockReport.item?.questionResults?.some(item => item.questionId === ids.questionThree && item.isCorrect === true && item.answerText === '已经对照参考答案完成自评'), 'mock report should include backend-persisted subjective self judgment', ); + assert.ok( + mockReport.item?.autoBadges?.some(item => item.badgeId === ids.tenantPracticeBadge), + 'mock report submit should auto grant practice count badge', + ); + assert.ok( + mockReport.item?.autoBadges?.some(item => item.badgeId === ids.tenantMockExamBadge), + 'mock report submit should auto grant mock exam score badge', + ); const idempotentReport = await request('/api/learning/practice-sessions/submit', { userId: TENANT_ADMIN_USER_ID, @@ -2466,6 +2521,11 @@ async function testCatalogAndLearning() { }, }); assert.equal(idempotentReport.item?.id, mockReport.item.id, 'mock submit should be idempotent'); + assert.equal( + idempotentReport.item?.autoBadges?.filter(item => item.badgeId === ids.tenantMockExamBadge).length || 0, + 0, + 'idempotent mock submit should not duplicate automatic mock exam badges', + ); const fetchedReport = await request('/api/learning/practice-sessions/report', { userId: TENANT_ADMIN_USER_ID, @@ -3144,6 +3204,28 @@ async function testVocabulary() { const stats = await request('/api/learning/vocabulary/stats', { query: { unitId: ids.vocabularyUnit } }); assert.ok(stats.item?.totalWords >= 1, 'word stats should count smoke word'); + const vocabularyBadge = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + id: ids.tenantVocabularyBadge, + legacyId: 'integration-badge-vocabulary-mastered-auto', + name: '集成测试单词掌握勋章', + description: '掌握单词达到规则后自动发放', + category: 'vocabulary', + iconUrl: 'https://example.test/badges/vocabulary-mastered-auto.png', + level: 1, + unlockType: 'vocabulary_mastered', + conditionField: 'vocabularyMasteredCount', + conditionOperator: 'gte', + conditionValue: 1, + metadata: { source: 'integration-test', trigger: 'vocabulary_mastered' }, + order: 5, + isActive: true, + }, + }); + assert.equal(vocabularyBadge.item?.id, ids.tenantVocabularyBadge, 'tenant admin should create automatic vocabulary badge'); + const reviewUnknown = await request('/api/learning/vocabulary/review', { method: 'POST', body: { userId: USER_ID, wordId: ids.vocabularyWord, result: 'unknown' }, @@ -3162,6 +3244,10 @@ async function testVocabulary() { body: { userId: USER_ID, wordId: ids.vocabularyWord, status: 'mastered', correctDelta: 1 }, }); assert.equal(progress.item?.status, 'mastered', 'word progress should update to mastered'); + assert.ok( + progress.item?.autoBadges?.some(item => item.badgeId === ids.tenantVocabularyBadge), + 'mastered word progress should auto grant vocabulary badge', + ); const reviewKnown = await request('/api/learning/vocabulary/review', { method: 'POST', @@ -3170,6 +3256,11 @@ async function testVocabulary() { assert.equal(reviewKnown.item?.lastResult, 'known', 'word review should persist known result'); assert.ok(reviewKnown.item?.reviewCount >= 1, 'word review should increment review count'); assert.ok(reviewKnown.item?.nextReviewDate, 'word review should compute next review date'); + assert.equal( + reviewKnown.item?.autoBadges?.filter(item => item.badgeId === ids.tenantVocabularyBadge).length || 0, + 0, + 'repeated mastered word review should not duplicate vocabulary badge', + ); const favorite = await request('/api/learning/vocabulary/favorites', { method: 'POST', diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index d0e686f8..e605ab1c 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -75,6 +75,9 @@ const ids = { tenantScoreBadge: '00000000-0000-0000-0000-000000000874', tenantFeedbackBadge: '00000000-0000-0000-0000-000000000875', tenantActivityBadge: '00000000-0000-0000-0000-000000000876', + tenantPracticeBadge: '00000000-0000-0000-0000-000000000881', + tenantVocabularyBadge: '00000000-0000-0000-0000-000000000882', + tenantMockExamBadge: '00000000-0000-0000-0000-000000000883', pointActivityTask: '00000000-0000-0000-0000-000000000877', pointSystemTask: '00000000-0000-0000-0000-000000000878', pointExchangeItem: '00000000-0000-0000-0000-000000000879', @@ -357,13 +360,27 @@ async function main() { tenantId, ids.user, ids.secondStudentUser, - [ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge, ids.tenantActivityBadge], + [ + ids.tenantBadge, + ids.tenantCheckInBadge, + ids.tenantScoreBadge, + ids.tenantFeedbackBadge, + ids.tenantActivityBadge, + ids.tenantPracticeBadge, + ids.tenantVocabularyBadge, + ids.tenantMockExamBadge, + ], [ `badge:${ids.tenantBadge}:user:${ids.user}`, `auto:${ids.tenantCheckInBadge}:user:${ids.user}`, `auto:${ids.tenantScoreBadge}:user:${ids.user}`, `auto:${ids.tenantFeedbackBadge}:user:${ids.user}`, `auto:${ids.tenantActivityBadge}:user:${ids.user}`, + `auto:${ids.tenantPracticeBadge}:user:${ids.user}`, + `auto:${ids.tenantPracticeBadge}:user:${ids.tenantAdminUser}`, + `auto:${ids.tenantVocabularyBadge}:user:${ids.user}`, + `auto:${ids.tenantMockExamBadge}:user:${ids.user}`, + `auto:${ids.tenantMockExamBadge}:user:${ids.tenantAdminUser}`, ], ], ); @@ -377,7 +394,19 @@ async function main() { or coalesce(legacy_id, '') like 'integration-badge-%' ) `, - [tenantId, [ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge, ids.tenantActivityBadge]], + [ + tenantId, + [ + ids.tenantBadge, + ids.tenantCheckInBadge, + ids.tenantScoreBadge, + ids.tenantFeedbackBadge, + ids.tenantActivityBadge, + ids.tenantPracticeBadge, + ids.tenantVocabularyBadge, + ids.tenantMockExamBadge, + ], + ], ); await client.query( @@ -969,6 +998,7 @@ async function main() { ` update public.student_profiles set region_id = $3, + avatar_preset = 'male', last_check_in_date = null, stats = stats - 'checkInStreak' - 'lastCheckInPoints', updated_at = now()