forked from wangziqi/gongxue-base
feat: add automatic badge awards
This commit is contained in:
@@ -12,9 +12,9 @@
|
|||||||
|
|
||||||
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
|
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
|
||||||
- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
|
- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
|
||||||
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券规则/核销报表、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
|
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
|
||||||
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
||||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。
|
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值和反馈解决可返回自动获得勋章结果。
|
||||||
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。
|
||||||
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
|
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
|
||||||
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
|
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 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 待接。
|
- Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。
|
||||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||||
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
|
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
|
||||||
- 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
- 勋章管理、手动发放、签到连续天数、积分阈值和反馈解决自动发放已可联调;练习次数、单词掌握、模考成绩、积分兑换/活动任务联动、发放通知、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。
|
||||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习和 7 日答题榜当前排名第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
|
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习和 7 日答题榜当前排名第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。
|
||||||
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。
|
||||||
|
|
||||||
@@ -433,4 +433,4 @@ git diff --check
|
|||||||
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、个人中心任务系统、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
|
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、个人中心任务系统、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
|
||||||
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
|
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
|
||||||
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||||
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动深化,以及排行榜防刷/预聚合。
|
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分兑换和活动任务深化,以及排行榜防刷/预聚合。
|
||||||
|
|||||||
227
apps/api/src/features/profile/badges.ts
Normal file
227
apps/api/src/features/profile/badges.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import type pg from 'pg';
|
||||||
|
|
||||||
|
type JsonMap = Record<string, unknown>;
|
||||||
|
|
||||||
|
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<BadgeTrigger, string[]> = {
|
||||||
|
check_in: ['check_in'],
|
||||||
|
score: ['score'],
|
||||||
|
feedback_resolved: ['feedback_resolved'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELD_ALIASES: Record<string, string[]> = {
|
||||||
|
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<AutoBadgeGrant[]> {
|
||||||
|
const badges = await client.query<AutoBadgeRow>(
|
||||||
|
`
|
||||||
|
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<AutoBadgeGrant>(
|
||||||
|
`
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||||
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||||
import { query, queryOne, transaction } from '../../core/db.js';
|
import { query, queryOne, transaction } from '../../core/db.js';
|
||||||
|
import { autoGrantBadges } from './badges.js';
|
||||||
|
|
||||||
type JsonMap = Record<string, unknown>;
|
type JsonMap = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -394,17 +395,45 @@ export async function checkInRoute(ctx: RequestContext) {
|
|||||||
[tenantId, userId, today, JSON.stringify(nextStats)],
|
[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 {
|
return {
|
||||||
checkedIn: true,
|
checkedIn: true,
|
||||||
alreadyCheckedIn: false,
|
alreadyCheckedIn: false,
|
||||||
pointsAdded,
|
pointsAdded,
|
||||||
streak,
|
streak,
|
||||||
score: updatedUser.rows[0]?.score || balanceAfter,
|
score,
|
||||||
lastCheckInDate: today,
|
lastCheckInDate: today,
|
||||||
ledger: {
|
ledger: {
|
||||||
...ledger.rows[0],
|
...ledger.rows[0],
|
||||||
balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter,
|
balanceAfter: score || ledger.rows[0].balanceAfter,
|
||||||
},
|
},
|
||||||
|
autoBadges,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type pg from 'pg';
|
|||||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||||
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||||
import { query, transaction } from '../../core/db.js';
|
import { query, transaction } from '../../core/db.js';
|
||||||
|
import { autoGrantBadges, type AutoBadgeGrant } from '../profile/badges.js';
|
||||||
import {
|
import {
|
||||||
requireTenantAdmin,
|
requireTenantAdmin,
|
||||||
requireTenantPermission,
|
requireTenantPermission,
|
||||||
@@ -315,6 +316,7 @@ export async function updateTenantFeedbackStatusRoute(ctx: RequestContext) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let reward = null;
|
let reward = null;
|
||||||
|
let autoBadges: AutoBadgeGrant[] = [];
|
||||||
if (rewardPoints > 0 && current.rows[0].userId) {
|
if (rewardPoints > 0 && current.rows[0].userId) {
|
||||||
const currentScore = await client.query<{ score: number }>(
|
const currentScore = await client.query<{ score: number }>(
|
||||||
'select score from public.platform_users where id = $1 for update',
|
'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],
|
[current.rows[0].userId, rewardPoints],
|
||||||
);
|
);
|
||||||
reward = { ...ledger.rows[0], balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter };
|
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, {
|
await recordAudit(client, auth, 'tenant.feedback.status_updated', 'reports', reportId, {
|
||||||
fromStatus: current.rows[0].status,
|
fromStatus: current.rows[0].status,
|
||||||
toStatus: nextStatus,
|
toStatus: nextStatus,
|
||||||
rewardPoints,
|
rewardPoints,
|
||||||
|
autoBadgeCount: autoBadges.length,
|
||||||
});
|
});
|
||||||
return { ...result.rows[0], reward };
|
return { ...result.rows[0], reward, autoBadges };
|
||||||
});
|
});
|
||||||
|
|
||||||
return { item };
|
return { item };
|
||||||
|
|||||||
@@ -67,8 +67,8 @@
|
|||||||
| 学习排行榜 | 可联调 | `GET /api/learning/leaderboard`;支持 `questions`、`score`、`vocabulary`、`mock_exam` 四类指标,支持 `all`、`7d`、`30d` 周期和租户/地区/班级范围,返回当前用户排名并拒绝跨租户 session |
|
| 学习排行榜 | 可联调 | `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 /api/catalog/exam-dates`、`GET /api/profile/exam-countdowns`;返回租户/地区匹配考试日期和 `daysLeft` |
|
||||||
| 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转 |
|
| 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转 |
|
||||||
| 签到积分 | 可联调 | `POST /api/profile/check-in`、`GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分 |
|
| 签到积分 | 可联调 | `POST /api/profile/check-in`、`GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分;真实签到成功会触发 `check_in` 和 `score` 规则勋章自动发放,重复签到不重复发放 |
|
||||||
| 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态 |
|
| 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态;签到连续天数、积分阈值、反馈解决规则已支持自动发放 |
|
||||||
|
|
||||||
## 背单词、知识手册、分数线、视频
|
## 背单词、知识手册、分数线、视频
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
| 登录 provider 配置 | 可联调 | `/api/tenant-admin/auth-providers` |
|
| 登录 provider 配置 | 可联调 | `/api/tenant-admin/auth-providers` |
|
||||||
| 密钥掩码/引用 | 迁移期 | API 有掩码,生产前要做 KMS/Vault 或 envelope encryption |
|
| 密钥掩码/引用 | 迁移期 | API 有掩码,生产前要做 KMS/Vault 或 envelope encryption |
|
||||||
| 活动、Banner、FAQ、公告 | 可联调 | `/api/tenant-admin/banners`、`faqs`、`announcements` |
|
| 活动、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/exam-dates`,支持地区维度维护和公开倒计时展示 |
|
||||||
| 题目反馈处理 | 可联调 | `/api/tenant-admin/feedbacks`、`feedbacks/status`、`feedbacks/events`;支持状态流转、处理备注、审计事件和幂等奖励积分 |
|
| 题目反馈处理 | 可联调 | `/api/tenant-admin/feedbacks`、`feedbacks/status`、`feedbacks/events`;支持状态流转、处理备注、审计事件和幂等奖励积分 |
|
||||||
| 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches`、`activation-codes` |
|
| 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches`、`activation-codes` |
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
- 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。
|
- 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。
|
||||||
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
|
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
|
||||||
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API。
|
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换和资料下载的基础 API;签到、积分阈值和反馈解决可触发自动勋章发放。
|
||||||
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。
|
- 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。
|
||||||
- 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。
|
- 旧题库 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 深测继续执行 |
|
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 |
|
||||||
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 |
|
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 |
|
||||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到积分反馈自动发放、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列和分佣结算操作台第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
|
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
|
||||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 |
|
| 分数线 | 可联调 | 字段、院校、专业、记录、趋势、年份、JSON 批量导入 | 复杂动态筛选、AI 择校数据上下文 |
|
||||||
| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 |
|
| 视频解析会员 | 可联调 | 题目视频、批量查询、后台绑定、视频 JSON 导入、SVIP 权限、播放次数扣减、签名 URL、播放日志、动态水印上下文 | 深度防盗链、转码级水印、播放统计 |
|
||||||
| 资料下载/PDF | 可联调 | `content_assets` 资源台账、后台资源管理、OSS/COS/Supabase Storage 上传下载签名、上传确认、PDF/图片预览签名、学生端列表、SVIP 下载权限、动态水印上下文、assets worker 复检、内置安全扫描和外部 HTTP scanner 接入层 | CDN 防盗链、真实 AV/内容安全服务联调、资料前端管理页 |
|
| 资料下载/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、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
|
| CRM 系统 | 可联调 | CRM 配置、密钥私密存储、客资入队、队列查询、generic/钉钉/飞书/企微 worker、签名、重试和日志 | 定向/轮询分配、富卡片模板、失败告警、死信运营台 |
|
||||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
- 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。
|
||||||
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描、外部 HTTP scanner 接入层和租户后台媒体运营报表;Taro 学生资料页已接短期签名、水印 traceId 展示和强制水印容器第一版;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。
|
- 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描、外部 HTTP scanner 接入层和租户后台媒体运营报表;Taro 学生资料页已接短期签名、水印 traceId 展示和强制水印容器第一版;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。
|
||||||
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
|
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。
|
||||||
- 数据看板、分佣结算、财务运营、勋章手动发放和主题模板发布基础 API 已可联调;勋章自动发放、真实打款 provider、发票、真实生产账单抽样验收、AI 择校真实 provider、主题素材库/模板市场等仍是后续商用增强项。
|
- 数据看板、分佣结算、财务运营、勋章手动发放、签到/积分/反馈自动发放和主题模板发布基础 API 已可联调;练习/单词/模考触发勋章、真实打款 provider、发票、真实生产账单抽样验收、AI 择校真实 provider、主题素材库/模板市场等仍是后续商用增强项。
|
||||||
|
|
||||||
## 前后端协作建议
|
## 前后端协作建议
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
| 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、播放水印上下文、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放、水印 traceId 和导入断言 | 播放、权益、后台绑定、动态水印上下文和批量 JSON 导入链路已实现,深度防盗链、转码级水印和播放统计待补 |
|
| 题目视频讲解 | 已建 `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 级水印待补 |
|
| 资料下载/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 日答题榜当前排名、激活码和订单入口第一版;头像上传、账号合并、更细任务系统和学习建议待补 |
|
| 个人中心 | 已建 `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 分配策略、富卡片和销售转化看板待补 |
|
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
||||||
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
|
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
|
||||||
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 |
|
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 |
|
||||||
@@ -309,5 +309,5 @@ platform-admin:
|
|||||||
2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。
|
2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。
|
||||||
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
||||||
4. 补视频商用控制:深度防盗链、转码级水印和播放统计。
|
4. 补视频商用控制:深度防盗链、转码级水印和播放统计。
|
||||||
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。
|
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、积分兑换、活动任务和更多学习行为触发勋章。
|
||||||
6. 接真实短信/OAuth 生产账号、真实生产账单格式验收和异常订单运营台,并继续推进 Taro scaffold。
|
6. 接真实短信/OAuth 生产账号、真实生产账单格式验收和异常订单运营台,并继续推进 Taro scaffold。
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、Taro 学生端水印 traceId 预览/下载确认、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;仍缺深度防盗链、真实 AV/内容安全服务联调、转码/CDN 级水印和生命周期策略 |
|
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账、上传确认、签名下载、PDF/图片预览、动态水印上下文、Taro 学生端水印 traceId 预览/下载确认、worker 复检、内置安全扫描和外部 HTTP scanner 接入层已有;仍缺深度防盗链、真实 AV/内容安全服务联调、转码/CDN 级水印和生命周期策略 |
|
||||||
| AI 择校推荐 | 业务规划新增 | 部分覆盖 | 已有 SVIP 门禁、学生输入 schema、地区/分数线上下文、稳定 JSON 输出、报告台账、审计和 Taro 学生端基础页;真实 AI provider、prompt 版本管理后台、报告 PDF 渲染和更细推荐算法待补 |
|
| AI 择校推荐 | 业务规划新增 | 部分覆盖 | 已有 SVIP 门禁、学生输入 schema、地区/分数线上下文、稳定 JSON 输出、报告台账、审计和 Taro 学生端基础页;真实 AI provider、prompt 版本管理后台、报告 PDF 渲染和更细推荐算法待补 |
|
||||||
| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 |
|
| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 |
|
||||||
| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等已覆盖;缺积分兑换、活动任务和更完整的运营规则 |
|
| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等、签到/积分阈值自动发放勋章已覆盖;缺积分兑换、活动任务和更完整的运营规则 |
|
||||||
| 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 部分覆盖 | 已有刷题数、积分、背单词、模考最高分排行榜,支持租户/地区/班级范围和当前用户排名;后续补防刷、日/周榜预聚合、运营后台排名看板 |
|
| 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 部分覆盖 | 已有刷题数、积分、背单词、模考最高分排行榜,支持租户/地区/班级范围和当前用户排名;后续补防刷、日/周榜预聚合、运营后台排名看板 |
|
||||||
|
|
||||||
## 租户后台功能
|
## 租户后台功能
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
| SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI |
|
| SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI |
|
||||||
| 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券未核销幂等、下单抵扣、全额优惠自动开通权益、最低金额、封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细和报表已有;前端营销活动 UI 继续完善 |
|
| 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券未核销幂等、下单抵扣、全额优惠自动开通权益、最低金额、封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细和报表已有;前端营销活动 UI 继续完善 |
|
||||||
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
|
| 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 |
|
||||||
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示和权限隔离已覆盖;自动发放规则、积分活动联动和前端运营 UI 待补 |
|
| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示、权限隔离、签到连续天数、积分阈值和反馈解决自动发放已覆盖;练习次数、单词掌握、模考成绩、积分活动联动、发放通知和前端运营 UI 待补 |
|
||||||
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载、字段映射 API 和导入任务详情已有;Taro 租户内容页已接上传/粘贴预览、模板下载、字段别名编辑、同步/异步执行、异步轮询和复检结果详情第一版;真实数据验收待补 |
|
| 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 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 卡片和拼图包已补;后续补更精细试卷模板和后台操作台体验 |
|
| 题库导出 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 有基础;后台配置体验待补 |
|
| 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 |
|
||||||
@@ -104,8 +104,8 @@
|
|||||||
7. CRM/销售结算:CRM worker、跟进分配策略、分佣规则、结算单、审核、打款状态、导出和凭证复核基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、真实打款 provider、发票和销售结算看板。
|
7. CRM/销售结算:CRM worker、跟进分配策略、分佣规则、结算单、审核、打款状态、导出和凭证复核基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、真实打款 provider、发票和销售结算看板。
|
||||||
8. AI 择校推荐增强:后端 `local_rules` 地基、SVIP 门禁、报告列表/详情和 Taro 基础页已完成;仍缺真实 AI provider、prompt 编排、PDF 报告和后台运营配置。
|
8. AI 择校推荐增强:后端 `local_rules` 地基、SVIP 门禁、报告列表/详情和 Taro 基础页已完成;仍缺真实 AI provider、prompt 编排、PDF 报告和后台运营配置。
|
||||||
9. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
|
9. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。
|
||||||
10. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。
|
10. 积分活动增强:积分兑换、活动任务、连续签到奖励配置和风控。
|
||||||
11. 勋章增强:后台维护和手动发放已有;仍缺按学习行为、签到、积分、活动任务自动发放,以及发放通知。
|
11. 勋章增强:后台维护、手动发放、签到/积分/反馈自动发放已有;仍缺按练习次数、单词掌握、模考成绩、活动任务自动发放,以及发放通知。
|
||||||
|
|
||||||
### P0:前端联调到云端前
|
### P0:前端联调到云端前
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||||
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。
|
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。
|
||||||
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。
|
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。
|
||||||
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;后续补自动发放规则和活动联动。
|
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值和反馈解决自动发放第一阶段已完成并纳入 API 集成测试;后续补练习次数、单词掌握、模考成绩、活动任务联动和发放通知。
|
||||||
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
||||||
- 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。
|
- 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。
|
||||||
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
|
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
|
||||||
@@ -127,8 +127,8 @@
|
|||||||
|
|
||||||
9. 积分和反馈增强
|
9. 积分和反馈增强
|
||||||
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
||||||
- 已完成勋章后台维护、手动发放和学生端展示。
|
- 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决自动发放第一阶段。
|
||||||
- 继续补积分兑换、活动任务、连续签到奖励配置、勋章自动发放、处理通知和反馈聚合统计。
|
- 继续补积分兑换、活动任务、连续签到奖励配置、练习/单词/模考触发勋章、处理通知和反馈聚合统计。
|
||||||
|
|
||||||
10. 数据看板
|
10. 数据看板
|
||||||
- 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。
|
- 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。
|
||||||
|
|||||||
@@ -647,7 +647,17 @@ GET /api/tenant-admin/badge-grants?userId=...&badgeId=...
|
|||||||
POST /api/tenant-admin/badge-grants
|
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,
|
"pointsAdded": 10,
|
||||||
"streak": 1,
|
"streak": 1,
|
||||||
"score": 10,
|
"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` 时展示今日已签到,不要本地再加分。
|
- `alreadyCheckedIn=true` 时展示今日已签到,不要本地再加分。
|
||||||
- 积分明细调用 `GET /api/profile/score-events`。
|
- 积分明细调用 `GET /api/profile/score-events`。
|
||||||
- 积分最终余额以后端 `score` 和流水为准,前端只做展示。
|
- 积分最终余额以后端 `score` 和流水为准,前端只做展示。
|
||||||
|
- 如响应包含 `autoBadges`,可展示获得勋章弹层;不要在前端根据连续天数或积分自行判断勋章是否解锁。
|
||||||
|
|
||||||
题目页、资料页或视频页可提交反馈:
|
题目页、资料页或视频页可提交反馈:
|
||||||
|
|
||||||
@@ -1145,7 +1166,7 @@ POST /api/profile/check-in
|
|||||||
|
|
||||||
- `questionId` 如存在,后端会校验题目必须属于当前租户。
|
- `questionId` 如存在,后端会校验题目必须属于当前租户。
|
||||||
- 反馈状态由租户后台处理,学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。
|
- 反馈状态由租户后台处理,学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。
|
||||||
- 租户后台处理反馈时,奖励积分由后端 `idempotency_key` 保证不会重复发放,前端不要重复叠加。
|
- 租户后台处理反馈时,奖励积分由后端 `idempotency_key` 保证不会重复发放,前端不要重复叠加;状态处理响应可能返回 `autoBadges`,后台或学生消息中心后续可据此做获得勋章提醒。
|
||||||
|
|
||||||
## 题库新模型接入方式
|
## 题库新模型接入方式
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ const ids = {
|
|||||||
examDate: '00000000-0000-0000-0000-000000000861',
|
examDate: '00000000-0000-0000-0000-000000000861',
|
||||||
tenantExamDate: '00000000-0000-0000-0000-000000000862',
|
tenantExamDate: '00000000-0000-0000-0000-000000000862',
|
||||||
tenantBadge: '00000000-0000-0000-0000-000000000871',
|
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',
|
questionBank: '00000000-0000-0000-0000-000000000400',
|
||||||
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
|
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(typeof profileExamDate?.daysLeft, 'number', 'profile countdown should include daysLeft');
|
||||||
assert.equal(countdowns.target?.regionId, ids.region, 'profile countdown should use student target region');
|
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' });
|
const checkIn = await request('/api/profile/check-in', { method: 'POST' });
|
||||||
assert.equal(checkIn.item?.checkedIn, true, 'student should be able to check in');
|
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?.pointsAdded >= 10, 'check-in should add points');
|
||||||
assert.ok(checkIn.item?.score >= checkIn.item?.pointsAdded, 'check-in should return updated score');
|
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.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' });
|
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?.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?.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?.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 } });
|
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');
|
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', {
|
const feedback = await request('/api/profile/feedbacks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
@@ -1815,6 +1885,7 @@ async function testAiSchoolRecommendationAfterSvip() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
regionId: ids.region,
|
regionId: ids.region,
|
||||||
|
targetSchoolId: ids.scorelineSchool,
|
||||||
estimatedScore: 210,
|
estimatedScore: 210,
|
||||||
riskPreference: 'balanced',
|
riskPreference: 'balanced',
|
||||||
constraints: '优先考虑计算机相关专业',
|
constraints: '优先考虑计算机相关专业',
|
||||||
@@ -6566,6 +6637,28 @@ async function testTenantAdminOps() {
|
|||||||
});
|
});
|
||||||
assert.equal(studentFeedbackDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant feedback admin');
|
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', {
|
const resolvedFeedback = await request('/api/tenant-admin/feedbacks/status', {
|
||||||
userId: TENANT_ADMIN_USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -6581,6 +6674,10 @@ async function testTenantAdminOps() {
|
|||||||
});
|
});
|
||||||
assert.equal(resolvedFeedback.item?.status, 'resolved', 'tenant admin should resolve feedback');
|
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.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 rewardEventsAfterResolve = await request('/api/profile/score-events', { query: { limit: 50 } });
|
||||||
const rewardCountAfterResolve = rewardEventsAfterResolve.items?.filter(
|
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?.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 rewardEventsAfterRepeat = await request('/api/profile/score-events', { query: { limit: 50 } });
|
||||||
const rewardCountAfterRepeat = rewardEventsAfterRepeat.items?.filter(
|
const rewardCountAfterRepeat = rewardEventsAfterRepeat.items?.filter(
|
||||||
@@ -6607,6 +6709,15 @@ async function testTenantAdminOps() {
|
|||||||
).length || 0;
|
).length || 0;
|
||||||
assert.equal(rewardCountAfterRepeat, 1, 'feedback reward should not duplicate on repeated status updates');
|
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', {
|
const feedbackEvents = await request('/api/tenant-admin/feedbacks/events', {
|
||||||
userId: TENANT_ADMIN_USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
query: { reportId: pendingFeedback.id },
|
query: { reportId: pendingFeedback.id },
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ const ids = {
|
|||||||
tenantClassOther: '00000000-0000-0000-0000-000000000852',
|
tenantClassOther: '00000000-0000-0000-0000-000000000852',
|
||||||
examDate: '00000000-0000-0000-0000-000000000861',
|
examDate: '00000000-0000-0000-0000-000000000861',
|
||||||
examDateSchool: '00000000-0000-0000-0000-000000000862',
|
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',
|
partnerTenant: '00000000-0000-0000-0000-000000000901',
|
||||||
partnerSubscription: '00000000-0000-0000-0000-000000000902',
|
partnerSubscription: '00000000-0000-0000-0000-000000000902',
|
||||||
partnerInvoice: '00000000-0000-0000-0000-000000000903',
|
partnerInvoice: '00000000-0000-0000-0000-000000000903',
|
||||||
@@ -230,6 +234,66 @@ async function main() {
|
|||||||
[tenantId, ids.user, ids.tenantAdminUser, ids.secondStudentUser, ids.leaderboardScoreEventSecond],
|
[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(
|
await client.query(
|
||||||
`
|
`
|
||||||
delete from public.practice_session_report_sections
|
delete from public.practice_session_report_sections
|
||||||
|
|||||||
Reference in New Issue
Block a user