From e035f5be9ab809c36da17878ebbf12005a350475 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 02:50:13 +0800 Subject: [PATCH] feat: add automatic badge awards --- README.md | 8 +- apps/api/src/features/profile/badges.ts | 227 ++++++++++++++++++ apps/api/src/features/profile/routes.ts | 33 ++- .../src/features/tenant-admin/operations.ts | 47 +++- docs/refactor/backend-capability-status.md | 6 +- docs/refactor/backend-handoff-roadmap.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 | 8 +- docs/refactor/next-development-todo.md | 6 +- docs/refactor/taro-frontend-integration.md | 27 ++- scripts/api-integration-test.js | 111 +++++++++ scripts/smoke-seed.js | 64 +++++ 14 files changed, 523 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/features/profile/badges.ts diff --git a/README.md b/README.md index a66182c6..2dc32612 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 - `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 -- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券规则/核销报表、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 +- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 -- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。 +- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值和反馈解决可返回自动获得勋章结果。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。 @@ -34,7 +34,7 @@ - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 -- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 +- 勋章管理、手动发放、签到连续天数、积分阈值和反馈解决自动发放已可联调;练习次数、单词掌握、模考成绩、积分兑换/活动任务联动、发放通知、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 - `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习和 7 日答题榜当前排名第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 @@ -433,4 +433,4 @@ git diff --check 2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、个人中心任务系统、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台租户详情/审计/自动计费增强,小程序兼容验证。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 -5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。 +5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分兑换和活动任务深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/profile/badges.ts b/apps/api/src/features/profile/badges.ts new file mode 100644 index 00000000..2d6073da --- /dev/null +++ b/apps/api/src/features/profile/badges.ts @@ -0,0 +1,227 @@ +import type pg from 'pg'; + +type JsonMap = Record; + +export type BadgeTrigger = 'check_in' | 'score' | 'feedback_resolved'; + +export interface BadgeMetricEvidence { + checkInStreak?: number; + lastCheckInPoints?: number; + score?: number; + feedbackResolvedCount?: number; + rewardPoints?: number; + [key: string]: unknown; +} + +interface AutoBadgeRow { + id: string; + legacyId: string | null; + name: string; + description: string | null; + category: string | null; + iconUrl: string | null; + level: number | null; + unlockType: string | null; + conditionField: string | null; + conditionOperator: string | null; + conditionValue: string | number | null; + conditionExtra: JsonMap; + metadata: JsonMap; +} + +export interface AutoBadgeGrant { + id: string; + legacyId: string | null; + userId: string; + badgeId: string; + note: string | null; + metadata: JsonMap; + grantedAt: string | null; + createdAt: string; + updatedAt: string; + badge: { + id: string; + legacyId: string | null; + name: string; + description: string | null; + category: string | null; + iconUrl: string | null; + level: number | null; + unlockType: string | null; + }; +} + +const TRIGGER_UNLOCK_TYPES: Record = { + check_in: ['check_in'], + score: ['score'], + feedback_resolved: ['feedback_resolved'], +}; + +const FIELD_ALIASES: Record = { + check_in: ['checkInStreak', 'check_in.streak', 'checkIn.streak', 'profile.checkInStreak', 'stats.checkInStreak'], + last_check_in_points: ['lastCheckInPoints', 'check_in.points', 'checkIn.points', 'stats.lastCheckInPoints'], + score: ['score', 'user.score', 'profile.score'], + feedback_resolved: ['feedbackResolvedCount', 'feedback.resolvedCount', 'reports.resolvedCount'], + reward_points: ['rewardPoints', 'feedback.rewardPoints'], +}; + +function numberValue(value: unknown) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function textValue(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function objectValue(value: unknown): JsonMap { + return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonMap : {}; +} + +function metricValueForField(field: string | null, evidence: BadgeMetricEvidence, trigger: BadgeTrigger) { + const normalized = textValue(field); + if (!normalized) { + if (trigger === 'check_in') return numberValue(evidence.checkInStreak); + if (trigger === 'score') return numberValue(evidence.score); + if (trigger === 'feedback_resolved') return numberValue(evidence.feedbackResolvedCount); + return null; + } + + for (const [canonical, aliases] of Object.entries(FIELD_ALIASES)) { + if (canonical === normalized || aliases.includes(normalized)) { + if (canonical === 'check_in') return numberValue(evidence.checkInStreak); + if (canonical === 'last_check_in_points') return numberValue(evidence.lastCheckInPoints); + if (canonical === 'score') return numberValue(evidence.score); + if (canonical === 'feedback_resolved') return numberValue(evidence.feedbackResolvedCount); + if (canonical === 'reward_points') return numberValue(evidence.rewardPoints); + } + } + + const direct = evidence[normalized]; + return numberValue(direct); +} + +function compareMetric(current: number, target: number, operator: string | null) { + switch (operator || 'gte') { + case 'gte': + return current >= target; + case 'gt': + return current > target; + case 'lte': + return current <= target; + case 'lt': + return current < target; + case 'eq': + return current === target; + default: + return false; + } +} + +function triggerMatches(row: AutoBadgeRow, trigger: BadgeTrigger) { + const unlockType = row.unlockType || 'manual'; + if (TRIGGER_UNLOCK_TYPES[trigger].includes(unlockType)) return true; + if (unlockType !== 'auto') return false; + + const extra = objectValue(row.conditionExtra); + const triggers = Array.isArray(extra.triggers) + ? extra.triggers.filter(item => typeof item === 'string') + : []; + const triggerName = textValue(extra.trigger); + return triggerName === trigger || triggers.includes(trigger); +} + +function ruleSatisfied(row: AutoBadgeRow, trigger: BadgeTrigger, evidence: BadgeMetricEvidence) { + const target = numberValue(row.conditionValue); + if (target === null) return false; + const current = metricValueForField(row.conditionField, evidence, trigger); + if (current === null) return false; + return compareMetric(current, target, row.conditionOperator); +} + +export async function autoGrantBadges( + client: pg.PoolClient, + input: { + tenantId: string; + userId: string; + trigger: BadgeTrigger; + evidence: BadgeMetricEvidence; + }, +): Promise { + const badges = await client.query( + ` + select id, legacy_id as "legacyId", name, description, category, + icon_url as "iconUrl", level, unlock_type as "unlockType", + condition_field as "conditionField", condition_operator as "conditionOperator", + condition_value as "conditionValue", condition_extra as "conditionExtra", + metadata + from public.badges + where tenant_id = $1 + and is_active = true + and coalesce(unlock_type, 'manual') <> 'manual' + order by sort_order asc, level asc nulls last, created_at asc + `, + [input.tenantId], + ); + + const grants: AutoBadgeGrant[] = []; + for (const badge of badges.rows) { + if (!triggerMatches(badge, input.trigger)) continue; + if (!ruleSatisfied(badge, input.trigger, input.evidence)) continue; + + const currentValue = metricValueForField(badge.conditionField, input.evidence, input.trigger); + const metadata = { + source: 'auto', + trigger: input.trigger, + rule: { + field: badge.conditionField, + operator: badge.conditionOperator || 'gte', + value: numberValue(badge.conditionValue), + }, + evidence: { + ...input.evidence, + matchedValue: currentValue, + }, + }; + + const result = await client.query( + ` + insert into public.user_badges ( + tenant_id, user_id, badge_id, granted_by, legacy_id, + note, metadata, granted_at + ) + values ($1, $2, $3, null, $4, $5, $6::jsonb, now()) + on conflict (tenant_id, user_id, badge_id) do nothing + returning id, legacy_id as "legacyId", user_id as "userId", + badge_id as "badgeId", note, metadata, + granted_at as "grantedAt", created_at as "createdAt", + updated_at as "updatedAt" + `, + [ + input.tenantId, + input.userId, + badge.id, + `auto:${badge.id}:user:${input.userId}`, + `自动发放:${badge.name}`, + JSON.stringify(metadata), + ], + ); + + if (!result.rows[0]) continue; + grants.push({ + ...result.rows[0], + badge: { + id: badge.id, + legacyId: badge.legacyId, + name: badge.name, + description: badge.description, + category: badge.category, + iconUrl: badge.iconUrl, + level: badge.level, + unlockType: badge.unlockType, + }, + }); + } + + return grants; +} diff --git a/apps/api/src/features/profile/routes.ts b/apps/api/src/features/profile/routes.ts index 24036d92..ee46cccb 100644 --- a/apps/api/src/features/profile/routes.ts +++ b/apps/api/src/features/profile/routes.ts @@ -1,6 +1,7 @@ import { HttpError, type RequestContext } from '../../core/http.js'; import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js'; import { query, queryOne, transaction } from '../../core/db.js'; +import { autoGrantBadges } from './badges.js'; type JsonMap = Record; @@ -394,17 +395,45 @@ export async function checkInRoute(ctx: RequestContext) { [tenantId, userId, today, JSON.stringify(nextStats)], ); + const score = updatedUser.rows[0]?.score || balanceAfter; + const autoBadges = [ + ...(await autoGrantBadges(client, { + tenantId, + userId, + trigger: 'check_in', + evidence: { + checkInDate: today, + checkInStreak: streak, + lastCheckInPoints: pointsAdded, + score, + }, + })), + ...(await autoGrantBadges(client, { + tenantId, + userId, + trigger: 'score', + evidence: { + source: 'check_in', + checkInDate: today, + checkInStreak: streak, + lastCheckInPoints: pointsAdded, + score, + }, + })), + ]; + return { checkedIn: true, alreadyCheckedIn: false, pointsAdded, streak, - score: updatedUser.rows[0]?.score || balanceAfter, + score, lastCheckInDate: today, ledger: { ...ledger.rows[0], - balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter, + balanceAfter: score || ledger.rows[0].balanceAfter, }, + autoBadges, }; }); diff --git a/apps/api/src/features/tenant-admin/operations.ts b/apps/api/src/features/tenant-admin/operations.ts index 0b1af7ff..78b29938 100644 --- a/apps/api/src/features/tenant-admin/operations.ts +++ b/apps/api/src/features/tenant-admin/operations.ts @@ -2,6 +2,7 @@ import type pg from 'pg'; import { HttpError, type RequestContext } from '../../core/http.js'; import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js'; import { query, transaction } from '../../core/db.js'; +import { autoGrantBadges, type AutoBadgeGrant } from '../profile/badges.js'; import { requireTenantAdmin, requireTenantPermission, @@ -315,6 +316,7 @@ export async function updateTenantFeedbackStatusRoute(ctx: RequestContext) { ); let reward = null; + let autoBadges: AutoBadgeGrant[] = []; if (rewardPoints > 0 && current.rows[0].userId) { const currentScore = await client.query<{ score: number }>( 'select score from public.platform_users where id = $1 for update', @@ -354,15 +356,58 @@ export async function updateTenantFeedbackStatusRoute(ctx: RequestContext) { [current.rows[0].userId, rewardPoints], ); reward = { ...ledger.rows[0], balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter }; + autoBadges = [ + ...autoBadges, + ...(await autoGrantBadges(client, { + tenantId: auth.tenantId, + userId: current.rows[0].userId, + trigger: 'score', + evidence: { + source: 'feedback_reward', + reportId, + rewardPoints, + score: reward.balanceAfter, + }, + })), + ]; } } + if (nextStatus === 'resolved' && current.rows[0].status !== 'resolved' && current.rows[0].userId) { + const feedbackStats = await client.query<{ resolvedCount: string }>( + ` + select count(*)::text as "resolvedCount" + from public.reports + where tenant_id = $1 + and user_id = $2 + and status = 'resolved' + `, + [auth.tenantId, current.rows[0].userId], + ); + + autoBadges = [ + ...autoBadges, + ...(await autoGrantBadges(client, { + tenantId: auth.tenantId, + userId: current.rows[0].userId, + trigger: 'feedback_resolved', + evidence: { + reportId, + feedbackResolvedCount: Number(feedbackStats.rows[0]?.resolvedCount || 0), + rewardPoints, + status: nextStatus, + }, + })), + ]; + } + await recordAudit(client, auth, 'tenant.feedback.status_updated', 'reports', reportId, { fromStatus: current.rows[0].status, toStatus: nextStatus, rewardPoints, + autoBadgeCount: autoBadges.length, }); - return { ...result.rows[0], reward }; + return { ...result.rows[0], reward, autoBadges }; }); return { item }; diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 7d14a893..d08627cf 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -67,8 +67,8 @@ | 学习排行榜 | 可联调 | `GET /api/learning/leaderboard`;支持 `questions`、`score`、`vocabulary`、`mock_exam` 四类指标,支持 `all`、`7d`、`30d` 周期和租户/地区/班级范围,返回当前用户排名并拒绝跨租户 session | | 考试倒计时 | 可联调 | `GET /api/catalog/exam-dates`、`GET /api/profile/exam-countdowns`;返回租户/地区匹配考试日期和 `daysLeft` | | 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转 | -| 签到积分 | 可联调 | `POST /api/profile/check-in`、`GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分 | -| 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态 | +| 签到积分 | 可联调 | `POST /api/profile/check-in`、`GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分;真实签到成功会触发 `check_in` 和 `score` 规则勋章自动发放,重复签到不重复发放 | +| 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态;签到连续天数、积分阈值、反馈解决规则已支持自动发放 | ## 背单词、知识手册、分数线、视频 @@ -134,7 +134,7 @@ | 登录 provider 配置 | 可联调 | `/api/tenant-admin/auth-providers` | | 密钥掩码/引用 | 迁移期 | API 有掩码,生产前要做 KMS/Vault 或 envelope encryption | | 活动、Banner、FAQ、公告 | 可联调 | `/api/tenant-admin/banners`、`faqs`、`announcements` | -| 勋章管理/发放 | 可联调 | `/api/tenant-admin/badges`、`/api/tenant-admin/badge-grants`;支持后台维护、同 `legacyId` 幂等更新、手动发放、重复发放幂等、租户隔离和权限点 `badges:read/write/grant` | +| 勋章管理/发放 | 可联调 | `/api/tenant-admin/badges`、`/api/tenant-admin/badge-grants`;支持后台维护、同 `legacyId` 幂等更新、手动发放、重复发放幂等、租户隔离和权限点 `badges:read/write/grant`;`unlockType=check_in/score/feedback_resolved` 会由签到、积分奖励和反馈解决事件自动发放 | | 考试日期维护 | 可联调 | `/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/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 01bde525..4aaf3bde 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -10,7 +10,7 @@ - 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。 - 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。 -- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API。 +- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API;签到、积分阈值和反馈解决可触发自动勋章发放。 - 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。 - 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。 @@ -22,7 +22,7 @@ | --- | --- | --- | --- | | 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 | | 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 | -| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 | +| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到积分反馈自动发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 | | 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 | diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index 44b5166f..e221dc55 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 69ccb538..fce9d9db 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -55,7 +55,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 已可联调;勋章自动发放、真实打款 provider、发票、真实生产账单抽样验收、AI 择校真实 provider、主题素材库/模板市场等仍是后续商用增强项。 +- 数据看板、分佣结算、财务运营、勋章手动发放、签到/积分/反馈自动发放和主题模板发布基础 API 已可联调;练习/单词/模考触发勋章、真实打款 provider、发票、真实生产账单抽样验收、AI 择校真实 provider、主题素材库/模板市场等仍是后续商用增强项。 ## 前后端协作建议 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 459ef506..e5d00816 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` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试、Taro 类型检查 | 学生端个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、激活码和订单入口第一版;头像上传、账号合并、更细任务系统和学习建议待补 | -| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护和手动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,勋章自动发放、积分活动联动、营销自动化和前端活动操作台继续补 | +| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈自动发放已实现 | 核心 API 集成测试 | 基础运营后台可用,积分兑换、活动任务、练习/单词/模考触发勋章、营销自动化和前端活动操作台继续补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI | | 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 | @@ -309,5 +309,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 cd7dabce..8a2ebe05 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -35,7 +35,7 @@ | 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、Taro 学生端水印 traceId 预览/下载确认、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;仍缺深度防盗链、真实 AV/内容安全服务联调、转码/CDN 级水印和生命周期策略 | | AI 择校推荐 | 业务规划新增 | 部分覆盖 | 已有 SVIP 门禁、学生输入 schema、地区/分数线上下文、稳定 JSON 输出、报告台账、审计和 Taro 学生端基础页;真实 AI provider、prompt 版本管理后台、报告 PDF 渲染和更细推荐算法待补 | | 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 | -| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等已覆盖;缺积分兑换、活动任务和更完整的运营规则 | +| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等、签到/积分阈值自动发放勋章已覆盖;缺积分兑换、活动任务和更完整的运营规则 | | 排行榜 | `leaderboard.pb.js`、`02-API接口.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 有基础;后台配置体验待补 | @@ -104,8 +104,8 @@ 7. CRM/销售结算:CRM worker、跟进分配策略、分佣规则、结算单、审核、打款状态、导出和凭证复核基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、真实打款 provider、发票和销售结算看板。 8. AI 择校推荐增强:后端 `local_rules` 地基、SVIP 门禁、报告列表/详情和 Taro 基础页已完成;仍缺真实 AI provider、prompt 编排、PDF 报告和后台运营配置。 9. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 -10. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。 -11. 勋章增强:后台维护和手动发放已有;仍缺按学习行为、签到、积分、活动任务自动发放,以及发放通知。 +10. 积分活动增强:积分兑换、活动任务、连续签到奖励配置和风控。 +11. 勋章增强:后台维护、手动发放、签到/积分/反馈自动发放已有;仍缺按练习次数、单词掌握、模考成绩、活动任务自动发放,以及发放通知。 ### P0:前端联调到云端前 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index b15d995f..2291f7c7 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -22,7 +22,7 @@ - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。 - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。 -- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;后续补自动发放规则和活动联动。 +- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值和反馈解决自动发放第一阶段已完成并纳入 API 集成测试;后续补练习次数、单词掌握、模考成绩、活动任务联动和发放通知。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 - 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。 - 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。 @@ -127,8 +127,8 @@ 9. 积分和反馈增强 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 - - 已完成勋章后台维护、手动发放和学生端展示。 - - 继续补积分兑换、活动任务、连续签到奖励配置、勋章自动发放、处理通知和反馈聚合统计。 + - 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决自动发放第一阶段。 + - 继续补积分兑换、活动任务、连续签到奖励配置、练习/单词/模考触发勋章、处理通知和反馈聚合统计。 10. 数据看板 - 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 82240192..975f7f6b 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -647,7 +647,17 @@ GET /api/tenant-admin/badge-grants?userId=...&badgeId=... POST /api/tenant-admin/badge-grants ``` -`PUT /api/tenant-admin/badges` 支持同租户内 `legacyId` 幂等更新;如果 `id` 与 `legacyId` 指向不同记录会返回 `BADGE_ID_CONFLICT`。`POST /api/tenant-admin/badge-grants` 对同一用户同一勋章幂等,不会重复生成多条发放记录。当前后端支持手动发放,自动发放规则后续由 worker/事件流补齐。 +`PUT /api/tenant-admin/badges` 支持同租户内 `legacyId` 幂等更新;如果 `id` 与 `legacyId` 指向不同记录会返回 `BADGE_ID_CONFLICT`。`POST /api/tenant-admin/badge-grants` 对同一用户同一勋章幂等,不会重复生成多条发放记录。 + +当前后端已支持第一批自动发放规则: + +| unlockType | 推荐 conditionField | 触发时机 | +| --- | --- | --- | +| `check_in` | `checkInStreak` | `POST /api/profile/check-in` 真实签到成功后 | +| `score` | `score` | 签到加分或反馈奖励积分成功后 | +| `feedback_resolved` | `feedbackResolvedCount` | 租户后台把反馈处理为 `resolved` 后 | + +规则使用 `conditionOperator` 的合法值 `gte`、`gt`、`lte`、`lt`、`eq`;`conditionValue` 为数字。触发成功的接口会返回 `autoBadges`,前端可据此弹出“获得勋章”提示;如果是重复签到、重复处理反馈或已获得过同一勋章,后端不会重复返回同一发放记录。 ### 模考交卷与报告 @@ -1117,7 +1127,17 @@ POST /api/profile/check-in "pointsAdded": 10, "streak": 1, "score": 10, - "lastCheckInDate": "2026-06-29" + "lastCheckInDate": "2026-06-29", + "autoBadges": [ + { + "badgeId": "...", + "badge": { + "name": "连续签到", + "category": "activity", + "iconUrl": "https://..." + } + } + ] } } ``` @@ -1127,6 +1147,7 @@ POST /api/profile/check-in - `alreadyCheckedIn=true` 时展示今日已签到,不要本地再加分。 - 积分明细调用 `GET /api/profile/score-events`。 - 积分最终余额以后端 `score` 和流水为准,前端只做展示。 +- 如响应包含 `autoBadges`,可展示获得勋章弹层;不要在前端根据连续天数或积分自行判断勋章是否解锁。 题目页、资料页或视频页可提交反馈: @@ -1145,7 +1166,7 @@ POST /api/profile/check-in - `questionId` 如存在,后端会校验题目必须属于当前租户。 - 反馈状态由租户后台处理,学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。 -- 租户后台处理反馈时,奖励积分由后端 `idempotency_key` 保证不会重复发放,前端不要重复叠加。 +- 租户后台处理反馈时,奖励积分由后端 `idempotency_key` 保证不会重复发放,前端不要重复叠加;状态处理响应可能返回 `autoBadges`,后台或学生消息中心后续可据此做获得勋章提醒。 ## 题库新模型接入方式 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 45f356d3..4ef80679 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -52,6 +52,9 @@ const ids = { examDate: '00000000-0000-0000-0000-000000000861', tenantExamDate: '00000000-0000-0000-0000-000000000862', tenantBadge: '00000000-0000-0000-0000-000000000871', + tenantCheckInBadge: '00000000-0000-0000-0000-000000000873', + tenantScoreBadge: '00000000-0000-0000-0000-000000000874', + tenantFeedbackBadge: '00000000-0000-0000-0000-000000000875', questionBank: '00000000-0000-0000-0000-000000000400', publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906', }; @@ -1685,20 +1688,87 @@ async function testProfile() { assert.equal(typeof profileExamDate?.daysLeft, 'number', 'profile countdown should include daysLeft'); assert.equal(countdowns.target?.regionId, ids.region, 'profile countdown should use student target region'); + const checkInBadge = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + id: ids.tenantCheckInBadge, + legacyId: 'integration-badge-check-in-auto', + name: '集成测试签到勋章', + description: '连续签到达到规则后自动发放', + category: 'activity', + iconUrl: 'https://example.test/badges/check-in-auto.png', + level: 1, + unlockType: 'check_in', + conditionField: 'checkInStreak', + conditionOperator: 'gte', + conditionValue: 1, + metadata: { source: 'integration-test', trigger: 'check_in' }, + order: 1, + isActive: true, + }, + }); + assert.equal(checkInBadge.item?.id, ids.tenantCheckInBadge, 'tenant admin should create automatic check-in badge'); + + const scoreBadge = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + id: ids.tenantScoreBadge, + legacyId: 'integration-badge-score-auto', + name: '集成测试积分勋章', + description: '积分达到规则后自动发放', + category: 'activity', + iconUrl: 'https://example.test/badges/score-auto.png', + level: 1, + unlockType: 'score', + conditionField: 'score', + conditionOperator: 'gte', + conditionValue: 10, + metadata: { source: 'integration-test', trigger: 'score' }, + order: 2, + isActive: true, + }, + }); + assert.equal(scoreBadge.item?.id, ids.tenantScoreBadge, 'tenant admin should create automatic score badge'); + const checkIn = await request('/api/profile/check-in', { method: 'POST' }); assert.equal(checkIn.item?.checkedIn, true, 'student should be able to check in'); assert.ok(checkIn.item?.pointsAdded >= 10, 'check-in should add points'); assert.ok(checkIn.item?.score >= checkIn.item?.pointsAdded, 'check-in should return updated score'); assert.equal(checkIn.item?.ledger?.eventType, 'check_in', 'check-in should write score ledger'); + assert.ok( + checkIn.item?.autoBadges?.some(item => item.badgeId === ids.tenantCheckInBadge), + 'check-in should auto grant check-in badge', + ); + assert.ok( + checkIn.item?.autoBadges?.some(item => item.badgeId === ids.tenantScoreBadge), + 'check-in should auto grant score threshold badge', + ); const duplicateCheckIn = await request('/api/profile/check-in', { method: 'POST' }); assert.equal(duplicateCheckIn.item?.checkedIn, false, 'duplicate daily check-in should be idempotent'); assert.equal(duplicateCheckIn.item?.alreadyCheckedIn, true, 'duplicate daily check-in should report already checked in'); assert.equal(duplicateCheckIn.item?.pointsAdded, 0, 'duplicate daily check-in should not add points'); + assert.equal(duplicateCheckIn.item?.autoBadges, undefined, 'duplicate check-in should not auto grant badges'); const scoreEvents = await request('/api/profile/score-events', { query: { limit: 20 } }); assert.ok(scoreEvents.items?.some(item => item.eventType === 'check_in'), 'score ledger should include check-in event'); + const autoProfileBadges = await request('/api/profile/badges', { + query: { includeLocked: 'true', category: 'activity', limit: 20 }, + }); + assert.equal( + autoProfileBadges.items?.find(item => item.badgeId === ids.tenantCheckInBadge)?.unlocked, + true, + 'profile badges should expose auto check-in badge as unlocked', + ); + assert.equal( + autoProfileBadges.items?.find(item => item.badgeId === ids.tenantScoreBadge)?.unlocked, + true, + 'profile badges should expose auto score badge as unlocked', + ); + const feedback = await request('/api/profile/feedbacks', { method: 'POST', body: { @@ -1815,6 +1885,7 @@ async function testAiSchoolRecommendationAfterSvip() { method: 'POST', body: { regionId: ids.region, + targetSchoolId: ids.scorelineSchool, estimatedScore: 210, riskPreference: 'balanced', constraints: '优先考虑计算机相关专业', @@ -6566,6 +6637,28 @@ async function testTenantAdminOps() { }); assert.equal(studentFeedbackDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant feedback admin'); + const feedbackBadge = await request('/api/tenant-admin/badges', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + id: ids.tenantFeedbackBadge, + legacyId: 'integration-badge-feedback-auto', + name: '集成测试纠错勋章', + description: '反馈被解决达到规则后自动发放', + category: 'feedback', + iconUrl: 'https://example.test/badges/feedback-auto.png', + level: 1, + unlockType: 'feedback_resolved', + conditionField: 'feedbackResolvedCount', + conditionOperator: 'gte', + conditionValue: 1, + metadata: { source: 'integration-test', trigger: 'feedback_resolved' }, + order: 1, + isActive: true, + }, + }); + assert.equal(feedbackBadge.item?.id, ids.tenantFeedbackBadge, 'tenant admin should create automatic feedback badge'); + const resolvedFeedback = await request('/api/tenant-admin/feedbacks/status', { userId: TENANT_ADMIN_USER_ID, method: 'POST', @@ -6581,6 +6674,10 @@ async function testTenantAdminOps() { }); assert.equal(resolvedFeedback.item?.status, 'resolved', 'tenant admin should resolve feedback'); assert.equal(resolvedFeedback.item?.reward?.eventType, 'feedback_reward', 'feedback reward should write score ledger'); + assert.ok( + resolvedFeedback.item?.autoBadges?.some(item => item.badgeId === ids.tenantFeedbackBadge), + 'resolved feedback should auto grant feedback badge', + ); const rewardEventsAfterResolve = await request('/api/profile/score-events', { query: { limit: 50 } }); const rewardCountAfterResolve = rewardEventsAfterResolve.items?.filter( @@ -6600,6 +6697,11 @@ async function testTenantAdminOps() { }, }); assert.equal(repeatedReward.item?.reward, null, 'repeated feedback reward should be idempotent'); + assert.equal( + repeatedReward.item?.autoBadges?.filter(item => item.badgeId === ids.tenantFeedbackBadge).length || 0, + 0, + 'repeated feedback resolution should not duplicate auto feedback badge', + ); const rewardEventsAfterRepeat = await request('/api/profile/score-events', { query: { limit: 50 } }); const rewardCountAfterRepeat = rewardEventsAfterRepeat.items?.filter( @@ -6607,6 +6709,15 @@ async function testTenantAdminOps() { ).length || 0; assert.equal(rewardCountAfterRepeat, 1, 'feedback reward should not duplicate on repeated status updates'); + const feedbackProfileBadges = await request('/api/profile/badges', { + query: { includeLocked: 'true', category: 'feedback', limit: 20 }, + }); + assert.equal( + feedbackProfileBadges.items?.find(item => item.badgeId === ids.tenantFeedbackBadge)?.unlocked, + true, + 'profile badges should expose auto feedback badge as unlocked', + ); + const feedbackEvents = await request('/api/tenant-admin/feedbacks/events', { userId: TENANT_ADMIN_USER_ID, query: { reportId: pendingFeedback.id }, diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index 9afb76f6..c43ebf1a 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -70,6 +70,10 @@ const ids = { tenantClassOther: '00000000-0000-0000-0000-000000000852', examDate: '00000000-0000-0000-0000-000000000861', examDateSchool: '00000000-0000-0000-0000-000000000862', + tenantBadge: '00000000-0000-0000-0000-000000000871', + tenantCheckInBadge: '00000000-0000-0000-0000-000000000873', + tenantScoreBadge: '00000000-0000-0000-0000-000000000874', + tenantFeedbackBadge: '00000000-0000-0000-0000-000000000875', partnerTenant: '00000000-0000-0000-0000-000000000901', partnerSubscription: '00000000-0000-0000-0000-000000000902', partnerInvoice: '00000000-0000-0000-0000-000000000903', @@ -230,6 +234,66 @@ async function main() { [tenantId, ids.user, ids.tenantAdminUser, ids.secondStudentUser, ids.leaderboardScoreEventSecond], ); + await client.query( + ` + delete from public.audit_logs + where tenant_id = $1 + and target_type = 'ai_recommendation_report' + and target_id in ( + select id::text + from public.ai_recommendation_reports + where tenant_id = $1 + and user_id in ($2::uuid, $3::uuid) + ) + `, + [tenantId, ids.user, ids.secondStudentUser], + ); + + await client.query( + ` + delete from public.ai_recommendation_reports + where tenant_id = $1 + and user_id in ($2::uuid, $3::uuid) + `, + [tenantId, ids.user, ids.secondStudentUser], + ); + + await client.query( + ` + delete from public.user_badges + where tenant_id = $1 + and ( + user_id in ($2::uuid, $3::uuid) + or badge_id = any($4::uuid[]) + or coalesce(legacy_id, '') = any($5::text[]) + ) + `, + [ + tenantId, + ids.user, + ids.secondStudentUser, + [ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge], + [ + `badge:${ids.tenantBadge}:user:${ids.user}`, + `auto:${ids.tenantCheckInBadge}:user:${ids.user}`, + `auto:${ids.tenantScoreBadge}:user:${ids.user}`, + `auto:${ids.tenantFeedbackBadge}:user:${ids.user}`, + ], + ], + ); + + await client.query( + ` + delete from public.badges + where tenant_id = $1 + and ( + id = any($2::uuid[]) + or coalesce(legacy_id, '') like 'integration-badge-%' + ) + `, + [tenantId, [ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge]], + ); + await client.query( ` delete from public.practice_session_report_sections