forked from wangziqi/gongxue-base
feat: add user notification center
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 提交。
|
||||||
|
|
||||||
@@ -430,7 +430,7 @@ git diff --check
|
|||||||
优先继续补:
|
优先继续补:
|
||||||
|
|
||||||
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
||||||
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、个人中心积分任务/兑换 UI、背单词更细统计、小程序支付与分享,租户后台积分任务/兑换操作台、更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
|
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、个人中心积分任务/兑换 UI、用户消息中心 UI、背单词更细统计、小程序支付与分享,租户后台积分任务/兑换操作台、用户通知查看页、更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台租户详情/审计/自动计费增强,小程序兼容验证。
|
||||||
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
|
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
|
||||||
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||||
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。
|
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。
|
||||||
|
|||||||
180
apps/api/src/features/notifications/service.ts
Normal file
180
apps/api/src/features/notifications/service.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import type pg from 'pg';
|
||||||
|
import { HttpError } from '../../core/http.js';
|
||||||
|
|
||||||
|
export type NotificationStatus = 'unread' | 'read' | 'dismissed' | 'archived';
|
||||||
|
export type NotificationSeverity = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface UserNotificationRow {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
notificationType: string;
|
||||||
|
status: NotificationStatus;
|
||||||
|
severity: NotificationSeverity;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
actionLabel: string | null;
|
||||||
|
actionPath: string | null;
|
||||||
|
sourceType: string | null;
|
||||||
|
sourceId: string | null;
|
||||||
|
dedupeKey: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
createdBy: string | null;
|
||||||
|
readAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserNotificationInput {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
notificationType: string;
|
||||||
|
severity?: NotificationSeverity;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
actionLabel?: string | null;
|
||||||
|
actionPath?: string | null;
|
||||||
|
sourceType?: string | null;
|
||||||
|
sourceId?: string | null;
|
||||||
|
dedupeKey?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
createdBy?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USER_NOTIFICATION_STATUSES: NotificationStatus[] = ['unread', 'read', 'dismissed', 'archived'];
|
||||||
|
export const USER_NOTIFICATION_SEVERITIES: NotificationSeverity[] = ['info', 'success', 'warning', 'error'];
|
||||||
|
|
||||||
|
function nullableString(value: unknown) {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeText(value: unknown, key: string, maxLength: number) {
|
||||||
|
const text = nullableString(value);
|
||||||
|
if (!text) throw new HttpError(400, `${key} is required`, 'REQUIRED_FIELD');
|
||||||
|
if (text.length > maxLength) {
|
||||||
|
throw new HttpError(400, `${key} is too long`, 'FIELD_TOO_LONG');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeNullableText(value: unknown, key: string, maxLength: number) {
|
||||||
|
const text = nullableString(value);
|
||||||
|
if (!text) return null;
|
||||||
|
if (text.length > maxLength) {
|
||||||
|
throw new HttpError(400, `${key} is too long`, 'FIELD_TOO_LONG');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeToken(value: unknown, key: string, fallback: string | null = null) {
|
||||||
|
const text = nullableString(value) || fallback;
|
||||||
|
if (!text) return null;
|
||||||
|
if (!/^[a-z][a-z0-9_:-]{1,95}$/i.test(text)) {
|
||||||
|
throw new HttpError(400, `${key} contains unsupported characters`, 'INVALID_NOTIFICATION_TOKEN');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDedupeKey(value: unknown) {
|
||||||
|
const text = nullableString(value);
|
||||||
|
if (!text) return null;
|
||||||
|
if (!/^[a-zA-Z0-9:_./-]{4,180}$/.test(text)) {
|
||||||
|
throw new HttpError(400, 'dedupeKey contains unsupported characters', 'INVALID_NOTIFICATION_DEDUPE_KEY');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeUuid(value: unknown, key: string) {
|
||||||
|
const text = nullableString(value);
|
||||||
|
if (!text) return null;
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) {
|
||||||
|
throw new HttpError(400, `${key} must be a UUID`, 'INVALID_UUID');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNotificationStatus(value: unknown, fallback: NotificationStatus = 'unread') {
|
||||||
|
const status = (nullableString(value) || fallback) as NotificationStatus;
|
||||||
|
if (!USER_NOTIFICATION_STATUSES.includes(status)) {
|
||||||
|
throw new HttpError(400, `Invalid notification status: ${status}`, 'INVALID_NOTIFICATION_STATUS');
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNotificationSeverity(value: unknown, fallback: NotificationSeverity = 'info') {
|
||||||
|
const severity = (nullableString(value) || fallback) as NotificationSeverity;
|
||||||
|
if (!USER_NOTIFICATION_SEVERITIES.includes(severity)) {
|
||||||
|
throw new HttpError(400, `Invalid notification severity: ${severity}`, 'INVALID_NOTIFICATION_SEVERITY');
|
||||||
|
}
|
||||||
|
return severity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUserNotification(
|
||||||
|
client: Pick<pg.PoolClient, 'query'>,
|
||||||
|
input: CreateUserNotificationInput,
|
||||||
|
): Promise<UserNotificationRow | null> {
|
||||||
|
const notificationType = safeToken(input.notificationType, 'notificationType');
|
||||||
|
const severity = normalizeNotificationSeverity(input.severity, 'info');
|
||||||
|
const title = safeText(input.title, 'title', 120);
|
||||||
|
const message = safeText(input.message, 'message', 600);
|
||||||
|
const actionLabel = safeNullableText(input.actionLabel, 'actionLabel', 40);
|
||||||
|
const actionPath = safeNullableText(input.actionPath, 'actionPath', 240);
|
||||||
|
const sourceType = safeToken(input.sourceType, 'sourceType', null);
|
||||||
|
const sourceId = safeUuid(input.sourceId, 'sourceId');
|
||||||
|
const dedupeKey = safeDedupeKey(input.dedupeKey);
|
||||||
|
|
||||||
|
const result = await client.query<UserNotificationRow>(
|
||||||
|
`
|
||||||
|
insert into public.user_notifications (
|
||||||
|
tenant_id, user_id, notification_type, status, severity,
|
||||||
|
title, message, action_label, action_path,
|
||||||
|
source_type, source_id, dedupe_key, metadata, created_by
|
||||||
|
)
|
||||||
|
values (
|
||||||
|
$1, $2, $3, 'unread', $4,
|
||||||
|
$5, $6, $7, $8,
|
||||||
|
$9, $10::uuid, $11, $12::jsonb, $13::uuid
|
||||||
|
)
|
||||||
|
on conflict (tenant_id, user_id, notification_type, dedupe_key)
|
||||||
|
where dedupe_key is not null
|
||||||
|
do update set severity = excluded.severity,
|
||||||
|
title = excluded.title,
|
||||||
|
message = excluded.message,
|
||||||
|
action_label = excluded.action_label,
|
||||||
|
action_path = excluded.action_path,
|
||||||
|
source_type = excluded.source_type,
|
||||||
|
source_id = excluded.source_id,
|
||||||
|
metadata = public.user_notifications.metadata || excluded.metadata,
|
||||||
|
created_by = coalesce(public.user_notifications.created_by, excluded.created_by),
|
||||||
|
updated_at = now()
|
||||||
|
returning id, tenant_id as "tenantId", user_id as "userId",
|
||||||
|
notification_type as "notificationType", status, severity,
|
||||||
|
title, message, action_label as "actionLabel",
|
||||||
|
action_path as "actionPath", source_type as "sourceType",
|
||||||
|
source_id as "sourceId", dedupe_key as "dedupeKey",
|
||||||
|
metadata, created_by as "createdBy", read_at as "readAt",
|
||||||
|
created_at as "createdAt", updated_at as "updatedAt"
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
input.tenantId,
|
||||||
|
input.userId,
|
||||||
|
notificationType,
|
||||||
|
severity,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
actionLabel,
|
||||||
|
actionPath,
|
||||||
|
sourceType,
|
||||||
|
sourceId,
|
||||||
|
dedupeKey,
|
||||||
|
JSON.stringify(objectValue(input.metadata)),
|
||||||
|
safeUuid(input.createdBy, 'createdBy'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] || null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type pg from 'pg';
|
import type pg from 'pg';
|
||||||
|
import { createUserNotification } from '../notifications/service.js';
|
||||||
|
|
||||||
type JsonMap = Record<string, unknown>;
|
type JsonMap = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -212,6 +213,28 @@ export async function autoGrantBadges(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!result.rows[0]) continue;
|
if (!result.rows[0]) continue;
|
||||||
|
await createUserNotification(client, {
|
||||||
|
tenantId: input.tenantId,
|
||||||
|
userId: input.userId,
|
||||||
|
notificationType: 'badge_granted',
|
||||||
|
severity: 'success',
|
||||||
|
title: `获得勋章:${badge.name}`,
|
||||||
|
message: badge.description || '你已解锁新的学习勋章。',
|
||||||
|
actionLabel: '查看勋章',
|
||||||
|
actionPath: '/student/profile?tab=badges',
|
||||||
|
sourceType: 'user_badges',
|
||||||
|
sourceId: result.rows[0].id,
|
||||||
|
dedupeKey: `badge:${badge.id}:user:${input.userId}`,
|
||||||
|
metadata: {
|
||||||
|
source: 'auto_badge_grant',
|
||||||
|
trigger: input.trigger,
|
||||||
|
badgeId: badge.id,
|
||||||
|
badgeName: badge.name,
|
||||||
|
badgeCategory: badge.category,
|
||||||
|
badgeLevel: badge.level,
|
||||||
|
evidence: input.evidence,
|
||||||
|
},
|
||||||
|
});
|
||||||
grants.push({
|
grants.push({
|
||||||
...result.rows[0],
|
...result.rows[0],
|
||||||
badge: {
|
badge: {
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
exchangeItemsRoute,
|
exchangeItemsRoute,
|
||||||
redeemExchangeItemRoute,
|
redeemExchangeItemRoute,
|
||||||
} from './points.js';
|
} from './points.js';
|
||||||
|
import {
|
||||||
|
profileNotificationsRoute,
|
||||||
|
updateProfileNotificationStatusRoute,
|
||||||
|
} from './notifications.js';
|
||||||
|
|
||||||
export const profileRoutes: RouteDefinition[] = [
|
export const profileRoutes: RouteDefinition[] = [
|
||||||
['GET', '/api/profile/me', profileMeRoute],
|
['GET', '/api/profile/me', profileMeRoute],
|
||||||
@@ -25,6 +29,8 @@ export const profileRoutes: RouteDefinition[] = [
|
|||||||
['POST', '/api/profile/activity-tasks/claim', claimActivityTaskRoute],
|
['POST', '/api/profile/activity-tasks/claim', claimActivityTaskRoute],
|
||||||
['GET', '/api/profile/exchange-items', exchangeItemsRoute],
|
['GET', '/api/profile/exchange-items', exchangeItemsRoute],
|
||||||
['POST', '/api/profile/exchange-items/redeem', redeemExchangeItemRoute],
|
['POST', '/api/profile/exchange-items/redeem', redeemExchangeItemRoute],
|
||||||
|
['GET', '/api/profile/notifications', profileNotificationsRoute],
|
||||||
|
['POST', '/api/profile/notifications/status', updateProfileNotificationStatusRoute],
|
||||||
['GET', '/api/profile/badges', profileBadgesRoute],
|
['GET', '/api/profile/badges', profileBadgesRoute],
|
||||||
['GET', '/api/profile/feedbacks', feedbacksRoute],
|
['GET', '/api/profile/feedbacks', feedbacksRoute],
|
||||||
['POST', '/api/profile/feedbacks', submitFeedbackRoute],
|
['POST', '/api/profile/feedbacks', submitFeedbackRoute],
|
||||||
|
|||||||
129
apps/api/src/features/profile/notifications.ts
Normal file
129
apps/api/src/features/profile/notifications.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||||
|
import { intParam, readJsonBody, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||||
|
import { query } from '../../core/db.js';
|
||||||
|
import { normalizeNotificationStatus, USER_NOTIFICATION_STATUSES } from '../notifications/service.js';
|
||||||
|
|
||||||
|
const NOTIFICATION_TYPES = [
|
||||||
|
'feedback_status_updated',
|
||||||
|
'feedback_reward_granted',
|
||||||
|
'badge_granted',
|
||||||
|
'point_exchange_completed',
|
||||||
|
'point_exchange_pending_fulfillment',
|
||||||
|
];
|
||||||
|
|
||||||
|
function nullableString(value: unknown) {
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationIdsValue(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) throw new HttpError(400, 'notificationIds is required', 'NOTIFICATION_IDS_REQUIRED');
|
||||||
|
const ids = value.map(item => {
|
||||||
|
const id = nullableString(item);
|
||||||
|
if (!id || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
||||||
|
throw new HttpError(400, 'notificationIds must contain UUID values', 'INVALID_UUID');
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
if (ids.length === 0) throw new HttpError(400, 'notificationIds cannot be empty', 'NOTIFICATION_IDS_REQUIRED');
|
||||||
|
if (ids.length > 100) throw new HttpError(400, 'notificationIds cannot exceed 100 items', 'NOTIFICATION_IDS_TOO_MANY');
|
||||||
|
return Array.from(new Set(ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationTypeParam(ctx: RequestContext) {
|
||||||
|
const notificationType = stringParam(ctx, 'notificationType') || stringParam(ctx, 'type');
|
||||||
|
if (!notificationType) return '';
|
||||||
|
if (!NOTIFICATION_TYPES.includes(notificationType)) {
|
||||||
|
throw new HttpError(400, `Invalid notificationType: ${notificationType}`, 'INVALID_NOTIFICATION_TYPE');
|
||||||
|
}
|
||||||
|
return notificationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function profileNotificationsRoute(ctx: RequestContext) {
|
||||||
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
|
const userId = await userIdFrom(ctx);
|
||||||
|
const limit = intParam(ctx, 'limit', 50, 200);
|
||||||
|
const status = stringParam(ctx, 'status');
|
||||||
|
const notificationType = notificationTypeParam(ctx);
|
||||||
|
const params: unknown[] = [tenantId, userId];
|
||||||
|
const filters = ['tenant_id = $1', 'user_id = $2'];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
normalizeNotificationStatus(status);
|
||||||
|
params.push(status);
|
||||||
|
filters.push(`status = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (notificationType) {
|
||||||
|
params.push(notificationType);
|
||||||
|
filters.push(`notification_type = $${params.length}`);
|
||||||
|
}
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
const items = await query(
|
||||||
|
`
|
||||||
|
select id, notification_type as "notificationType", status, severity,
|
||||||
|
title, message, action_label as "actionLabel",
|
||||||
|
action_path as "actionPath", source_type as "sourceType",
|
||||||
|
source_id as "sourceId", metadata, read_at as "readAt",
|
||||||
|
created_at as "createdAt", updated_at as "updatedAt"
|
||||||
|
from public.user_notifications
|
||||||
|
where ${filters.join(' and ')}
|
||||||
|
order by created_at desc
|
||||||
|
limit $${params.length}
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryRows = await query<{ status: string; count: number }>(
|
||||||
|
`
|
||||||
|
select status, count(*)::int as count
|
||||||
|
from public.user_notifications
|
||||||
|
where tenant_id = $1 and user_id = $2
|
||||||
|
group by status
|
||||||
|
`,
|
||||||
|
[tenantId, userId],
|
||||||
|
);
|
||||||
|
const summary = Object.fromEntries(USER_NOTIFICATION_STATUSES.map(item => [item, 0]));
|
||||||
|
for (const row of summaryRows) summary[row.status] = Number(row.count || 0);
|
||||||
|
|
||||||
|
return { items, summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfileNotificationStatusRoute(ctx: RequestContext) {
|
||||||
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
|
const body = await readJsonBody(ctx);
|
||||||
|
const userId = await userIdFrom(ctx, body);
|
||||||
|
const notificationIds = notificationIdsValue(body.notificationIds);
|
||||||
|
const status = normalizeNotificationStatus(body.status, 'read');
|
||||||
|
if (status === 'unread') {
|
||||||
|
throw new HttpError(400, 'Use read, dismissed or archived for notification status updates', 'INVALID_NOTIFICATION_STATUS');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query<{ id: string; notificationType: string; status: string; readAt: string | null; updatedAt: string }>(
|
||||||
|
`
|
||||||
|
update public.user_notifications
|
||||||
|
set status = $4,
|
||||||
|
read_at = case
|
||||||
|
when $4 = 'read' then coalesce(read_at, now())
|
||||||
|
else read_at
|
||||||
|
end,
|
||||||
|
updated_at = now()
|
||||||
|
where tenant_id = $1
|
||||||
|
and user_id = $2
|
||||||
|
and id = any($3::uuid[])
|
||||||
|
returning id, notification_type as "notificationType", status, read_at as "readAt", updated_at as "updatedAt"
|
||||||
|
`,
|
||||||
|
[tenantId, userId, notificationIds, status],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.length !== notificationIds.length) {
|
||||||
|
throw new HttpError(404, 'Some notifications were not found for this user', 'NOTIFICATION_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
updatedCount: result.length,
|
||||||
|
status,
|
||||||
|
notificationIds: result.map(item => item.id),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { HttpError, type RequestContext } from '../../core/http.js';
|
|||||||
import { intParam, readJsonBody, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
import { intParam, readJsonBody, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||||
import { query, transaction } from '../../core/db.js';
|
import { query, transaction } from '../../core/db.js';
|
||||||
import { autoGrantBadges } from './badges.js';
|
import { autoGrantBadges } from './badges.js';
|
||||||
|
import { createUserNotification } from '../notifications/service.js';
|
||||||
|
|
||||||
type JsonMap = Record<string, unknown>;
|
type JsonMap = Record<string, unknown>;
|
||||||
|
|
||||||
@@ -771,6 +772,33 @@ export async function redeemExchangeItemRoute(ctx: RequestContext) {
|
|||||||
[userId, exchangeItem.costPoints],
|
[userId, exchangeItem.costPoints],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const finalStatus = order.rows[0].status;
|
||||||
|
await createUserNotification(client, {
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
notificationType: finalStatus === 'completed' ? 'point_exchange_completed' : 'point_exchange_pending_fulfillment',
|
||||||
|
severity: finalStatus === 'completed' ? 'success' : 'info',
|
||||||
|
title: finalStatus === 'completed' ? '积分兑换成功' : '积分兑换待发放',
|
||||||
|
message: finalStatus === 'completed'
|
||||||
|
? `你已成功兑换「${exchangeItem.title}」。`
|
||||||
|
: `你已提交「${exchangeItem.title}」兑换申请,运营人员会继续处理。`,
|
||||||
|
actionLabel: '查看兑换',
|
||||||
|
actionPath: '/student/profile?tab=points',
|
||||||
|
sourceType: 'user_point_exchange_orders',
|
||||||
|
sourceId: order.rows[0].id,
|
||||||
|
dedupeKey: `point_exchange:${order.rows[0].id}`,
|
||||||
|
metadata: {
|
||||||
|
itemId: exchangeItem.id,
|
||||||
|
itemCode: exchangeItem.code,
|
||||||
|
itemTitle: exchangeItem.title,
|
||||||
|
itemType: exchangeItem.itemType,
|
||||||
|
costPoints: exchangeItem.costPoints,
|
||||||
|
status: finalStatus,
|
||||||
|
couponRedemptionId: order.rows[0].couponRedemptionId,
|
||||||
|
assetId: order.rows[0].assetId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item: exchangeItem,
|
item: exchangeItem,
|
||||||
order: order.rows[0],
|
order: order.rows[0],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const TENANT_ADMIN_ROLES = new Set([
|
|||||||
const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
|
const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
|
||||||
tenant_owner: ['*'],
|
tenant_owner: ['*'],
|
||||||
tenant_admin: ['*'],
|
tenant_admin: ['*'],
|
||||||
tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'badges:*', 'codes:read', 'coupons:read', 'referral:read', 'commission:read', 'crm:read'],
|
tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'badges:*', 'notifications:read', 'codes:read', 'coupons:read', 'referral:read', 'commission:read', 'crm:read'],
|
||||||
teacher: ['content:*', 'classes:read', 'students:read', 'students:notes:*', 'students:followups:*'],
|
teacher: ['content:*', 'classes:read', 'students:read', 'students:notes:*', 'students:followups:*'],
|
||||||
sales: ['codes:*', 'coupons:read', 'coupons:write', 'referral:*', 'commission:self'],
|
sales: ['codes:*', 'coupons:read', 'coupons:write', 'referral:*', 'commission:self'],
|
||||||
agent: ['codes:read', 'coupons:read', 'referral:self', 'commission:self'],
|
agent: ['codes:read', 'coupons:read', 'referral:self', 'commission:self'],
|
||||||
@@ -105,6 +105,7 @@ export function tenantPermissionCatalog() {
|
|||||||
{ key: 'marketing:write', label: '活动内容管理' },
|
{ key: 'marketing:write', label: '活动内容管理' },
|
||||||
{ key: 'marketing:points:read', label: '积分任务/兑换查看' },
|
{ key: 'marketing:points:read', label: '积分任务/兑换查看' },
|
||||||
{ key: 'marketing:points:write', label: '积分任务/兑换管理' },
|
{ key: 'marketing:points:write', label: '积分任务/兑换管理' },
|
||||||
|
{ key: 'notifications:read', label: '用户站内通知查看' },
|
||||||
{ key: 'badges:read', label: '勋章查看' },
|
{ key: 'badges:read', label: '勋章查看' },
|
||||||
{ key: 'badges:write', label: '勋章管理' },
|
{ key: 'badges:write', label: '勋章管理' },
|
||||||
{ key: 'badges:grant', label: '勋章发放' },
|
{ key: 'badges:grant', label: '勋章发放' },
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
upsertTenantStudentRoute,
|
upsertTenantStudentRoute,
|
||||||
} from './classes.js';
|
} from './classes.js';
|
||||||
import { tenantDashboardRoute } from './dashboard.js';
|
import { tenantDashboardRoute } from './dashboard.js';
|
||||||
|
import { tenantUserNotificationsRoute } from './notifications.js';
|
||||||
import {
|
import {
|
||||||
tenantExamDatesRoute,
|
tenantExamDatesRoute,
|
||||||
tenantFeedbackEventsRoute,
|
tenantFeedbackEventsRoute,
|
||||||
@@ -101,6 +102,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [
|
|||||||
['GET', '/api/tenant-admin/teachers', tenantTeachersRoute],
|
['GET', '/api/tenant-admin/teachers', tenantTeachersRoute],
|
||||||
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],
|
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],
|
||||||
['GET', '/api/tenant-admin/dashboard', tenantDashboardRoute],
|
['GET', '/api/tenant-admin/dashboard', tenantDashboardRoute],
|
||||||
|
['GET', '/api/tenant-admin/user-notifications', tenantUserNotificationsRoute],
|
||||||
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
|
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
|
||||||
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],
|
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],
|
||||||
['GET', '/api/tenant-admin/theme-templates', tenantThemeTemplatesRoute],
|
['GET', '/api/tenant-admin/theme-templates', tenantThemeTemplatesRoute],
|
||||||
|
|||||||
92
apps/api/src/features/tenant-admin/notifications.ts
Normal file
92
apps/api/src/features/tenant-admin/notifications.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||||
|
import { intParam, stringParam } from '../../core/request.js';
|
||||||
|
import { query } from '../../core/db.js';
|
||||||
|
import { normalizeNotificationStatus } from '../notifications/service.js';
|
||||||
|
import { requireTenantAdmin, requireTenantPermission } from './auth.js';
|
||||||
|
|
||||||
|
const NOTIFICATION_TYPES = [
|
||||||
|
'feedback_status_updated',
|
||||||
|
'feedback_reward_granted',
|
||||||
|
'badge_granted',
|
||||||
|
'point_exchange_completed',
|
||||||
|
'point_exchange_pending_fulfillment',
|
||||||
|
];
|
||||||
|
|
||||||
|
function optionalUuidString(value: unknown, key: string) {
|
||||||
|
const candidate = typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
if (!candidate) return null;
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate)) {
|
||||||
|
throw new HttpError(400, `${key} must be a UUID`, 'INVALID_UUID');
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationTypeParam(ctx: RequestContext) {
|
||||||
|
const notificationType = stringParam(ctx, 'notificationType') || stringParam(ctx, 'type');
|
||||||
|
if (!notificationType) return '';
|
||||||
|
if (!NOTIFICATION_TYPES.includes(notificationType)) {
|
||||||
|
throw new HttpError(400, `Invalid notificationType: ${notificationType}`, 'INVALID_NOTIFICATION_TYPE');
|
||||||
|
}
|
||||||
|
return notificationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tenantUserNotificationsRoute(ctx: RequestContext) {
|
||||||
|
const auth = await requireTenantAdmin(ctx);
|
||||||
|
requireTenantPermission(auth, 'notifications:read');
|
||||||
|
const limit = intParam(ctx, 'limit', 100, 500);
|
||||||
|
const userId = optionalUuidString(stringParam(ctx, 'userId'), 'userId');
|
||||||
|
const status = stringParam(ctx, 'status');
|
||||||
|
const notificationType = notificationTypeParam(ctx);
|
||||||
|
const params: unknown[] = [auth.tenantId];
|
||||||
|
const filters = ['n.tenant_id = $1'];
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
params.push(userId);
|
||||||
|
filters.push(`n.user_id = $${params.length}::uuid`);
|
||||||
|
}
|
||||||
|
if (status) {
|
||||||
|
normalizeNotificationStatus(status);
|
||||||
|
params.push(status);
|
||||||
|
filters.push(`n.status = $${params.length}`);
|
||||||
|
}
|
||||||
|
if (notificationType) {
|
||||||
|
params.push(notificationType);
|
||||||
|
filters.push(`n.notification_type = $${params.length}`);
|
||||||
|
}
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
const items = await query(
|
||||||
|
`
|
||||||
|
select n.id, n.user_id as "userId", u.name as "userName",
|
||||||
|
u.phone as "userPhone", u.avatar_url as "userAvatarUrl",
|
||||||
|
n.notification_type as "notificationType", n.status, n.severity,
|
||||||
|
n.title, n.message, n.action_label as "actionLabel",
|
||||||
|
n.action_path as "actionPath", n.source_type as "sourceType",
|
||||||
|
n.source_id as "sourceId", n.metadata, n.created_by as "createdBy",
|
||||||
|
creator.name as "createdByName", n.read_at as "readAt",
|
||||||
|
n.created_at as "createdAt", n.updated_at as "updatedAt"
|
||||||
|
from public.user_notifications n
|
||||||
|
join public.platform_users u on u.id = n.user_id
|
||||||
|
left join public.platform_users creator on creator.id = n.created_by
|
||||||
|
where ${filters.join(' and ')}
|
||||||
|
order by n.created_at desc
|
||||||
|
limit $${params.length}
|
||||||
|
`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
|
||||||
|
const summaryRows = await query<{ status: string; count: number }>(
|
||||||
|
`
|
||||||
|
select status, count(*)::int as count
|
||||||
|
from public.user_notifications
|
||||||
|
where tenant_id = $1
|
||||||
|
group by status
|
||||||
|
`,
|
||||||
|
[auth.tenantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
summary: Object.fromEntries(summaryRows.map(item => [item.status, Number(item.count || 0)])),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ 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 { autoGrantBadges, type AutoBadgeGrant } from '../profile/badges.js';
|
||||||
|
import { createUserNotification } from '../notifications/service.js';
|
||||||
import {
|
import {
|
||||||
requireTenantAdmin,
|
requireTenantAdmin,
|
||||||
requireTenantPermission,
|
requireTenantPermission,
|
||||||
@@ -13,6 +14,13 @@ type JsonBody = Record<string, unknown>;
|
|||||||
|
|
||||||
const REPORT_STATUSES = ['pending', 'accepted', 'rejected', 'resolved', 'closed'];
|
const REPORT_STATUSES = ['pending', 'accepted', 'rejected', 'resolved', 'closed'];
|
||||||
const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
||||||
|
const REPORT_STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: '待处理',
|
||||||
|
accepted: '已受理',
|
||||||
|
rejected: '未采纳',
|
||||||
|
resolved: '已解决',
|
||||||
|
closed: '已关闭',
|
||||||
|
};
|
||||||
|
|
||||||
function nullableString(value: unknown) {
|
function nullableString(value: unknown) {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
@@ -31,6 +39,12 @@ function boolValue(value: unknown, fallback: boolean) {
|
|||||||
return typeof value === 'boolean' ? value : fallback;
|
return typeof value === 'boolean' ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function feedbackNotificationSeverity(status: string) {
|
||||||
|
if (status === 'resolved') return 'success';
|
||||||
|
if (status === 'rejected') return 'warning';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
function optionalChoice(value: unknown, allowed: string[], fallback: string) {
|
function optionalChoice(value: unknown, allowed: string[], fallback: string) {
|
||||||
const candidate = nullableString(value) || fallback;
|
const candidate = nullableString(value) || fallback;
|
||||||
if (!allowed.includes(candidate)) {
|
if (!allowed.includes(candidate)) {
|
||||||
@@ -401,6 +415,55 @@ export async function updateTenantFeedbackStatusRoute(ctx: RequestContext) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (current.rows[0].userId) {
|
||||||
|
const statusLabel = REPORT_STATUS_LABELS[nextStatus] || nextStatus;
|
||||||
|
const resolution = nullableString(body.resolution);
|
||||||
|
await createUserNotification(client, {
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
userId: current.rows[0].userId,
|
||||||
|
notificationType: 'feedback_status_updated',
|
||||||
|
severity: feedbackNotificationSeverity(nextStatus),
|
||||||
|
title: `反馈${statusLabel}`,
|
||||||
|
message: resolution || `你的反馈已更新为「${statusLabel}」。`,
|
||||||
|
actionLabel: '查看反馈',
|
||||||
|
actionPath: '/student/profile?tab=feedbacks',
|
||||||
|
sourceType: 'reports',
|
||||||
|
sourceId: reportId,
|
||||||
|
dedupeKey: `feedback_status:${reportId}:${nextStatus}`,
|
||||||
|
metadata: {
|
||||||
|
reportId,
|
||||||
|
fromStatus: current.rows[0].status,
|
||||||
|
toStatus: nextStatus,
|
||||||
|
rewardPoints,
|
||||||
|
autoBadgeCount: autoBadges.length,
|
||||||
|
},
|
||||||
|
createdBy: auth.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reward) {
|
||||||
|
await createUserNotification(client, {
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
userId: current.rows[0].userId,
|
||||||
|
notificationType: 'feedback_reward_granted',
|
||||||
|
severity: 'success',
|
||||||
|
title: '反馈奖励已发放',
|
||||||
|
message: `感谢你的反馈,本次已奖励 ${rewardPoints} 积分。`,
|
||||||
|
actionLabel: '查看积分',
|
||||||
|
actionPath: '/student/profile?tab=points',
|
||||||
|
sourceType: 'reports',
|
||||||
|
sourceId: reportId,
|
||||||
|
dedupeKey: `feedback_reward:${reportId}`,
|
||||||
|
metadata: {
|
||||||
|
reportId,
|
||||||
|
rewardPoints,
|
||||||
|
scoreEventId: reward.id,
|
||||||
|
balanceAfter: reward.balanceAfter,
|
||||||
|
},
|
||||||
|
createdBy: auth.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
tenantPermissionCatalog,
|
tenantPermissionCatalog,
|
||||||
type TenantAdminAuth,
|
type TenantAdminAuth,
|
||||||
} from './auth.js';
|
} from './auth.js';
|
||||||
|
import { createUserNotification } from '../notifications/service.js';
|
||||||
|
|
||||||
type JsonBody = Record<string, unknown>;
|
type JsonBody = Record<string, unknown>;
|
||||||
type SecretScope = 'payment' | 'sms' | 'oauth' | 'storage' | 'crm' | 'ai' | 'system';
|
type SecretScope = 'payment' | 'sms' | 'oauth' | 'storage' | 'crm' | 'ai' | 'system';
|
||||||
@@ -1954,6 +1955,27 @@ export async function grantBadgeRoute(ctx: RequestContext) {
|
|||||||
badgeName: badge.rows[0].name,
|
badgeName: badge.rows[0].name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await createUserNotification(client, {
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
userId,
|
||||||
|
notificationType: 'badge_granted',
|
||||||
|
severity: 'success',
|
||||||
|
title: `获得勋章:${badge.rows[0].name}`,
|
||||||
|
message: nullableString(body.note) || '管理员为你发放了一枚新的学习勋章。',
|
||||||
|
actionLabel: '查看勋章',
|
||||||
|
actionPath: '/student/profile?tab=badges',
|
||||||
|
sourceType: 'user_badges',
|
||||||
|
sourceId: result.rows[0].id,
|
||||||
|
dedupeKey: `badge:${badgeId}:user:${userId}`,
|
||||||
|
metadata: {
|
||||||
|
source: 'tenant_admin_badge_grant',
|
||||||
|
badgeId,
|
||||||
|
badgeName: badge.rows[0].name,
|
||||||
|
grantId: result.rows[0].id,
|
||||||
|
},
|
||||||
|
createdBy: auth.userId,
|
||||||
|
});
|
||||||
|
|
||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,23 @@ export interface PointExchangeItem {
|
|||||||
lastExchangedAt?: string | null;
|
lastExchangedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserNotificationItem {
|
||||||
|
id: string;
|
||||||
|
notificationType?: string;
|
||||||
|
status?: 'unread' | 'read' | 'dismissed' | 'archived';
|
||||||
|
severity?: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
actionLabel?: string | null;
|
||||||
|
actionPath?: string | null;
|
||||||
|
sourceType?: string | null;
|
||||||
|
sourceId?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
readAt?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadProfile() {
|
export async function loadProfile() {
|
||||||
return apiRequest<{ item?: StudentProfile }>('/api/profile/me');
|
return apiRequest<{ item?: StudentProfile }>('/api/profile/me');
|
||||||
}
|
}
|
||||||
@@ -127,6 +144,26 @@ export async function redeemExchangeItem(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadNotifications(query: {
|
||||||
|
status?: UserNotificationItem['status'];
|
||||||
|
notificationType?: string;
|
||||||
|
limit?: number;
|
||||||
|
} = {}) {
|
||||||
|
return apiRequest<{ items?: UserNotificationItem[]; summary?: Record<string, number> }>('/api/profile/notifications', {
|
||||||
|
query: { ...query, limit: query.limit || 50 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNotificationStatus(input: {
|
||||||
|
notificationIds: string[];
|
||||||
|
status: 'read' | 'dismissed' | 'archived';
|
||||||
|
}) {
|
||||||
|
return apiRequest<{ item?: Record<string, unknown> }>('/api/profile/notifications/status', {
|
||||||
|
method: 'POST',
|
||||||
|
body: input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadBadges() {
|
export async function loadBadges() {
|
||||||
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/profile/badges');
|
return apiRequest<{ items?: Record<string, unknown>[] }>('/api/profile/badges');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,29 @@ export interface TenantContentNotificationItem {
|
|||||||
resolvedAt?: string | null;
|
resolvedAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserNotificationAdminItem {
|
||||||
|
id: string;
|
||||||
|
userId?: string;
|
||||||
|
userName?: string | null;
|
||||||
|
userPhone?: string | null;
|
||||||
|
userAvatarUrl?: string | null;
|
||||||
|
notificationType?: string;
|
||||||
|
status?: 'unread' | 'read' | 'dismissed' | 'archived';
|
||||||
|
severity?: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
actionLabel?: string | null;
|
||||||
|
actionPath?: string | null;
|
||||||
|
sourceType?: string | null;
|
||||||
|
sourceId?: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
createdBy?: string | null;
|
||||||
|
createdByName?: string | null;
|
||||||
|
readAt?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImportIssueItem {
|
export interface ImportIssueItem {
|
||||||
id: string;
|
id: string;
|
||||||
rowNo?: number | null;
|
rowNo?: number | null;
|
||||||
@@ -1008,6 +1031,17 @@ export async function loadTenantContentNotifications(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadUserNotifications(input: {
|
||||||
|
userId?: string;
|
||||||
|
status?: UserNotificationAdminItem['status'];
|
||||||
|
notificationType?: string;
|
||||||
|
limit?: number;
|
||||||
|
} = {}) {
|
||||||
|
return apiRequest<{ items?: UserNotificationAdminItem[]; summary?: Record<string, number> }>('/api/tenant-admin/user-notifications', {
|
||||||
|
query: { ...input, limit: input.limit || 100 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateTenantContentNotificationStatus(input: {
|
export async function updateTenantContentNotificationStatus(input: {
|
||||||
notificationIds: string[];
|
notificationIds: string[];
|
||||||
status: 'read' | 'dismissed' | 'resolved';
|
status: 'read' | 'dismissed' | 'resolved';
|
||||||
|
|||||||
@@ -66,10 +66,11 @@
|
|||||||
| 错题复习计划 | 可联调 | `GET /api/learning/wrong-questions/review-plan` + `POST /api/learning/practice-sessions` 的 `mode=wrong_review`,后端从本人错题本安全组卷 |
|
| 错题复习计划 | 可联调 | `GET /api/learning/wrong-questions/review-plan` + `POST /api/learning/practice-sessions` 的 `mode=wrong_review`,后端从本人错题本安全组卷 |
|
||||||
| 学习排行榜 | 可联调 | `GET /api/learning/leaderboard`;支持 `questions`、`score`、`vocabulary`、`mock_exam` 四类指标,支持 `all`、`7d`、`30d` 周期和租户/地区/班级范围,返回当前用户排名并拒绝跨租户 session |
|
| 学习排行榜 | 可联调 | `GET /api/learning/leaderboard`;支持 `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`;积分流水幂等、事务加锁,重复签到不重复加分;真实签到成功会触发 `check_in` 和 `score` 规则勋章自动发放,重复签到不重复发放 |
|
| 签到积分 | 可联调 | `POST /api/profile/check-in`、`GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分;真实签到成功会触发 `check_in` 和 `score` 规则勋章自动发放,重复签到不重复发放 |
|
||||||
| 积分活动/兑换 | 可联调 | `GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem`;复用 `user_score_events` 积分账本,任务领取和兑换均事务加锁;手动/练习/单词/模考类任务有后端证据校验,反馈解决等系统任务禁止学生自领;兑换支持库存、个人限购、余额校验、幂等 key、优惠券履约和兑换订单 |
|
| 积分活动/兑换 | 可联调 | `GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem`;复用 `user_score_events` 积分账本,任务领取和兑换均事务加锁;手动/练习/单词/模考类任务有后端证据校验,反馈解决等系统任务禁止学生自领;兑换支持库存、个人限购、余额校验、幂等 key、优惠券履约、兑换订单和完成/待履约站内通知 |
|
||||||
| 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态;签到连续天数、积分阈值、反馈解决规则已支持自动发放 |
|
| 学生勋章 | 可联调 | `GET /api/profile/badges`;支持分类筛选、已解锁/未解锁展示,后端只返回当前租户当前用户的勋章状态;签到连续天数、积分阈值、反馈解决和积分活动规则已支持自动发放,自动/手动获得勋章会写入用户站内通知 |
|
||||||
|
| 学生站内通知 | 可联调 | `GET /api/profile/notifications`、`POST /api/profile/notifications/status`;学生只能查看和更新自己的通知,支持未读/已读/忽略/归档、类型筛选和状态汇总 |
|
||||||
|
|
||||||
## 背单词、知识手册、分数线、视频
|
## 背单词、知识手册、分数线、视频
|
||||||
|
|
||||||
@@ -136,13 +137,14 @@
|
|||||||
| 密钥掩码/引用 | 迁移期 | 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/point-activity-tasks`、`point-activity-claims`、`point-exchange-items`、`point-exchange-orders`;使用 `marketing:points:read/write` 或兼容 `marketing:read/write` 权限,支持任务配置、兑换商品配置、领取记录、兑换订单、审计、跨租户拒绝 |
|
| 积分任务/兑换配置 | 可联调 | `/api/tenant-admin/point-activity-tasks`、`point-activity-claims`、`point-exchange-items`、`point-exchange-orders`;使用 `marketing:points:read/write` 或兼容 `marketing:read/write` 权限,支持任务配置、兑换商品配置、领取记录、兑换订单、审计、跨租户拒绝 |
|
||||||
| 勋章管理/发放 | 可联调 | `/api/tenant-admin/badges`、`/api/tenant-admin/badge-grants`;支持后台维护、同 `legacyId` 幂等更新、手动发放、重复发放幂等、租户隔离和权限点 `badges:read/write/grant`;`unlockType=check_in/score/feedback_resolved/activity_reward` 会由签到、积分奖励、反馈解决和活动任务事件自动发放 |
|
| 勋章管理/发放 | 可联调 | `/api/tenant-admin/badges`、`/api/tenant-admin/badge-grants`;支持后台维护、同 `legacyId` 幂等更新、手动发放、重复发放幂等、租户隔离和权限点 `badges:read/write/grant`;`unlockType=check_in/score/feedback_resolved/activity_reward` 会由签到、积分奖励、反馈解决和活动任务事件自动发放,并写入用户站内通知 |
|
||||||
| 考试日期维护 | 可联调 | `/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` |
|
||||||
| 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members`、`permissions`、`role-templates`、`audit-logs` |
|
| 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members`、`permissions`、`role-templates`、`audit-logs` |
|
||||||
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 |
|
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 |
|
||||||
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 |
|
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 |
|
||||||
|
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
|
||||||
| 平台租户/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*` |
|
| 平台租户/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*` |
|
||||||
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
||||||
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
|
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
- 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。
|
- 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。
|
||||||
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
|
- 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。
|
||||||
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放。
|
- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、站内通知、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 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 配置/队列、分佣结算和积分任务/兑换 service 第一版 | 更细的数据范围组合、成员批量运营、积分任务/兑换操作台页面、真实打款/导出/凭证和完整权限菜单 |
|
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算和积分任务/兑换/用户通知 service 第一版 | 更细的数据范围组合、成员批量运营、积分任务/兑换操作台页面、用户通知查看页、真实打款/导出/凭证和完整权限菜单 |
|
||||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `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 关联 |
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
5. 个人中心
|
5. 个人中心
|
||||||
- 调 `GET /api/profile/me`。
|
- 调 `GET /api/profile/me`。
|
||||||
- 接会员权益、订单详情/状态轮询、优惠券、激活码预检查/兑换、错题本、收藏夹、学习统计、排行榜、勋章、积分活动任务和积分兑换。
|
- 接会员权益、订单详情/状态轮询、优惠券、激活码预检查/兑换、错题本、收藏夹、学习统计、排行榜、勋章、积分活动任务、积分兑换和站内通知。
|
||||||
|
|
||||||
6. 资料、视频和支付
|
6. 资料、视频和支付
|
||||||
- 资料下载、PDF 预览、视频播放必须先请求后端签名或权限检查。
|
- 资料下载、PDF 预览、视频播放必须先请求后端签名或权限检查。
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。
|
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。
|
||||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,确认 `migrationReadiness` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。
|
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,确认 `migrationReadiness` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。
|
||||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,积分任务/兑换 service 已接;平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算和积分任务/兑换 service 第一版;下一步补积分任务/兑换页面 UI、公式图片混排、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版,积分任务/兑换/站内通知 service 已接;平台后台关键写操作第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算和积分任务/兑换/用户通知 service 第一版;下一步补积分任务/兑换页面 UI、消息中心 UI、公式图片混排、更细数据范围 UI、平台后台审计增强和小程序兼容验证。
|
||||||
|
|
||||||
### P1:商用收费和运营能力
|
### P1:商用收费和运营能力
|
||||||
|
|
||||||
|
|||||||
@@ -8,20 +8,20 @@
|
|||||||
- `auth`:短信验证码登录、迁移期 session、微信小程序登录、OAuth provider 预留。
|
- `auth`:短信验证码登录、迁移期 session、微信小程序登录、OAuth provider 预留。
|
||||||
- `catalog`:公开题库、地区、内容入口、分类树、题目集合、练习蓝图、考试日期、手册、商品、SVIP 套餐、资料资源只读/下载接口。
|
- `catalog`:公开题库、地区、内容入口、分类树、题目集合、练习蓝图、考试日期、手册、商品、SVIP 套餐、资料资源只读/下载接口。
|
||||||
- `learning`:顺序/随机/全真模拟组卷 session、答题记录、错题、收藏、背单词进度/收藏/统计、排行榜。
|
- `learning`:顺序/随机/全真模拟组卷 session、答题记录、错题、收藏、背单词进度/收藏/统计、排行榜。
|
||||||
- `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈、勋章。
|
- `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈、勋章、站内通知。
|
||||||
- `scoreline`:分数线字段、院校、专业、记录、趋势、年份。
|
- `scoreline`:分数线字段、院校、专业、记录、趋势、年份。
|
||||||
- `video`:题目视频讲解、批量预加载、通用视频搜索。
|
- `video`:题目视频讲解、批量预加载、通用视频搜索。
|
||||||
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。
|
- `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。
|
||||||
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。
|
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。
|
||||||
- `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。
|
- `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。
|
||||||
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
|
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。
|
||||||
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
|
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。
|
||||||
- `tenant`:域名/租户解析。
|
- `tenant`:域名/租户解析。
|
||||||
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
|
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
|
||||||
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
|
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
|
||||||
- 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
- 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||||
- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
|
- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
|
||||||
- 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转;`user_score_events` 支持每日签到积分流水和反馈奖励幂等;`badges/user_badges` 支持租户后台勋章维护、手动发放、重复发放幂等和学生个人中心展示。
|
- 旧题库常用运营功能已补齐一批:`exam_dates` 支持学生端考试倒计时和租户后台维护;`reports/report_status_events` 支持学生题目反馈、租户后台状态流转;`user_score_events` 支持每日签到积分流水和反馈奖励幂等;`badges/user_badges` 支持租户后台勋章维护、手动发放、重复发放幂等和学生个人中心展示;`user_notifications` 支持反馈处理、反馈奖励、勋章发放和积分兑换的学生站内通知。
|
||||||
- 旧题库排行榜主链路已补齐:`GET /api/learning/leaderboard` 支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。
|
- 旧题库排行榜主链路已补齐:`GET /api/learning/leaderboard` 支持刷题、积分、背单词、模考四类指标,支持全量/7 天/30 天周期,以及租户/地区/班级范围。
|
||||||
- `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
|
- `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
|
||||||
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
|
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
|
||||||
@@ -256,7 +256,7 @@ GET /api/tenant-admin/audit-logs
|
|||||||
- `/api/commerce/refunds`、`/api/commerce/refunds/status` 和 `/api/commerce/refunds/notify/{provider}` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,通过 `query_provider_refund` 主动查询确认供应商退款结果,也可接收微信/支付宝退款通知 webhook,全额退款成功会撤销订单来源权益;`apps/worker --job commerce` 会定时补偿查询支付和退款状态;`/api/commerce/reconciliation/*` 已支持租户资金对账导入、预览、批次/明细/异常查询、差错工单状态流、事件轨迹和审计。
|
- `/api/commerce/refunds`、`/api/commerce/refunds/status` 和 `/api/commerce/refunds/notify/{provider}` 已提供内部退款状态机;退款权限拆分为 `tenant:refund:read/write/review`,可调用微信/支付宝发起退款,通过 `query_provider_refund` 主动查询确认供应商退款结果,也可接收微信/支付宝退款通知 webhook,全额退款成功会撤销订单来源权益;`apps/worker --job commerce` 会定时补偿查询支付和退款状态;`/api/commerce/reconciliation/*` 已支持租户资金对账导入、预览、批次/明细/异常查询、差错工单状态流、事件轨迹和审计。
|
||||||
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。
|
- 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。
|
||||||
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
|
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
|
||||||
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容、活动和勋章,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
|
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容、活动、勋章并查看用户站内通知,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
|
||||||
- 班级学生 API 会按 `tenant_memberships.role_template_id -> tenant_role_templates.data_scope`、成员显式权限和 `tenant_class_members` 共同确定可见范围;非全局权限教师只能查看自己负责班级的学生。
|
- 班级学生 API 会按 `tenant_memberships.role_template_id -> tenant_role_templates.data_scope`、成员显式权限和 `tenant_class_members` 共同确定可见范围;非全局权限教师只能查看自己负责班级的学生。
|
||||||
- 学生批量导入、批量分班、学生状态、备注和跟进任务都使用独立权限点;教师默认可为范围内学生写备注和跟进任务,但不能批量导入、禁用学生或放大可见班级。
|
- 学生批量导入、批量分班、学生状态、备注和跟进任务都使用独立权限点;教师默认可为范围内学生写备注和跟进任务,但不能批量导入、禁用学生或放大可见班级。
|
||||||
- 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。
|
- 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
- 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve` 的 `branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`。
|
- 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve` 的 `branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`。
|
||||||
- 租户后台可以接入角色模板和成员 API:`/api/tenant-admin/role-templates`、`/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。
|
- 租户后台可以接入角色模板和成员 API:`/api/tenant-admin/role-templates`、`/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。
|
||||||
- 租户后台可以接入勋章管理、手动发放、积分任务和积分兑换:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`、`GET/PUT /api/tenant-admin/point-activity-tasks`、`GET /api/tenant-admin/point-activity-claims`、`GET/PUT /api/tenant-admin/point-exchange-items`、`GET /api/tenant-admin/point-exchange-orders`;学生端用 `GET /api/profile/badges` 展示成就,并通过 `GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem` 接积分活动和兑换。
|
- 租户后台可以接入勋章管理、手动发放、积分任务和积分兑换:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`、`GET/PUT /api/tenant-admin/point-activity-tasks`、`GET /api/tenant-admin/point-activity-claims`、`GET/PUT /api/tenant-admin/point-exchange-items`、`GET /api/tenant-admin/point-exchange-orders`;学生端用 `GET /api/profile/badges` 展示成就,并通过 `GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem` 接积分活动和兑换。
|
||||||
|
- 学生消息中心可以接 `GET /api/profile/notifications`、`POST /api/profile/notifications/status`;租户后台可用 `GET /api/tenant-admin/user-notifications` 做用户通知查看。通知只做展示、跳转和已读/归档状态,业务权限和权益仍以后端源接口为准。
|
||||||
|
|
||||||
## 不能误认为已商用完成的部分
|
## 不能误认为已商用完成的部分
|
||||||
|
|
||||||
@@ -55,7 +56,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、主题素材库/模板市场等仍是后续商用增强项。
|
||||||
|
|
||||||
## 前后端协作建议
|
## 前后端协作建议
|
||||||
|
|
||||||
@@ -83,9 +84,9 @@
|
|||||||
| 知识手册 | `apps/taro/src/pages/student/handbook/index.tsx` | `handbook-subjects`、`handbook-chapters`、`handbook-entries`;已接 RichContent 阅读渲染第一版 |
|
| 知识手册 | `apps/taro/src/pages/student/handbook/index.tsx` | `handbook-subjects`、`handbook-chapters`、`handbook-entries`;已接 RichContent 阅读渲染第一版 |
|
||||||
| 分数线 | `apps/taro/src/pages/student/scoreline/index.tsx` | `scoreline/records` |
|
| 分数线 | `apps/taro/src/pages/student/scoreline/index.tsx` | `scoreline/records` |
|
||||||
| 资料 | `apps/taro/src/pages/student/assets/index.tsx` | `assets`、`assets/preview`、`assets/download`;已接短期签名、过期信息、可见水印覆盖、traceId 展示和强制水印资源外部预览限制 |
|
| 资料 | `apps/taro/src/pages/student/assets/index.tsx` | `assets`、`assets/preview`、`assets/download`;已接短期签名、过期信息、可见水印覆盖、traceId 展示和强制水印资源外部预览限制 |
|
||||||
| 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me`、`check-in`、`badges`、`exam-countdowns`、`svip-plans`、`orders`、`entitlements`、`activation-codes`、`leaderboard`、`learning/stats`、`learning/trend`、`practice-sessions/history`;`src/services/profile.ts` 已补积分任务/兑换 service,页面 UI 待接 |
|
| 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me`、`check-in`、`badges`、`exam-countdowns`、`svip-plans`、`orders`、`entitlements`、`activation-codes`、`leaderboard`、`learning/stats`、`learning/trend`、`practice-sessions/history`、`profile/notifications`、`profile/notifications/status`;`src/services/profile.ts` 已补积分任务/兑换/站内通知 service,页面 UI 待接 |
|
||||||
|
|
||||||
当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/复合题多小题/收藏/反馈/视频 -> 交卷报告逐题复盘 -> 错题/收藏复习”,背单词已经覆盖“单元 -> 今日计划/全单元/收藏练习 -> 卡片翻转 -> 发音 -> 认识/再记上报 -> 本地恢复”,资料页已经覆盖“列表 -> 申请预览/下载短签名 -> 展示水印 traceId -> H5 水印容器预览或确认下载”,个人中心已经覆盖“基础资料 -> 学习报告 -> 14 天趋势 -> 题型表现 -> 最近练习 -> 排名/勋章/签到 -> 会员/订单/激活码”,积分活动/兑换 service 已具备但页面 UI 待接,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。`apps/taro/src/components/RichContent.tsx` 是学生端题干、选项、解析和知识手册的统一安全渲染组件:它只支持受控 Markdown 图片、基础表格、粗体、代码和被 parser 识别出的公式 token;H5 端用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]`,私有题图可用 `asset:<uuid>`、`content_asset:<uuid>` 或 `/asset/<uuid>` 资源引用向后端申请短期预览签名。组件不执行导入内容中的任意 HTML/JS,也会拒绝 `javascript:`、`data:` 等危险图片地址。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补积分任务/兑换页面、小程序公式真机验收、题图资源字段化、小程序分享/支付容器体验。
|
当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/复合题多小题/收藏/反馈/视频 -> 交卷报告逐题复盘 -> 错题/收藏复习”,背单词已经覆盖“单元 -> 今日计划/全单元/收藏练习 -> 卡片翻转 -> 发音 -> 认识/再记上报 -> 本地恢复”,资料页已经覆盖“列表 -> 申请预览/下载短签名 -> 展示水印 traceId -> H5 水印容器预览或确认下载”,个人中心已经覆盖“基础资料 -> 学习报告 -> 14 天趋势 -> 题型表现 -> 最近练习 -> 排名/勋章/签到 -> 会员/订单/激活码”,积分活动/兑换/站内通知 service 已具备但页面 UI 待接,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。`apps/taro/src/components/RichContent.tsx` 是学生端题干、选项、解析和知识手册的统一安全渲染组件:它只支持受控 Markdown 图片、基础表格、粗体、代码和被 parser 识别出的公式 token;H5 端用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]`,私有题图可用 `asset:<uuid>`、`content_asset:<uuid>` 或 `/asset/<uuid>` 资源引用向后端申请短期预览签名。组件不执行导入内容中的任意 HTML/JS,也会拒绝 `javascript:`、`data:` 等危险图片地址。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补积分任务/兑换页面、消息中心页面、小程序公式真机验收、题图资源字段化、小程序分享/支付容器体验。
|
||||||
|
|
||||||
## 已落地的 Taro 租户后台页面
|
## 已落地的 Taro 租户后台页面
|
||||||
|
|
||||||
@@ -95,11 +96,11 @@
|
|||||||
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` |
|
| 数据看板 | `apps/taro/src/pages/tenant-admin/dashboard/index.tsx` | `tenant-admin/dashboard` |
|
||||||
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/teachers`、`tenant-admin/students`、`students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups` |
|
| 学生运营 | `apps/taro/src/pages/tenant-admin/students/index.tsx` | `tenant-admin/classes`、`tenant-admin/teachers`、`tenant-admin/students`、`students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups` |
|
||||||
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/exports/questions`、`tenant-content/exports/jobs`、`tenant-content/assets/sign-download`、`tenant-content/assets/sign-preview`、`tenant-content/assets/security-scan-events`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`、`tenant-content/notifications/status` |
|
| 题库内容 | `apps/taro/src/pages/tenant-admin/content/index.tsx` | `tenant-content/content-entries`、`tenant-content/imports`、`imports/detail`、`imports/issues`、`imports/field-mapping`、`imports/templates`、`imports/post-check`、`tenant-content/exports/questions`、`tenant-content/exports/jobs`、`tenant-content/assets/sign-download`、`tenant-content/assets/sign-preview`、`tenant-content/assets/security-scan-events`、`tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`、`tenant-content/notifications/status` |
|
||||||
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/config`、`crm/queue`、`commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`;`src/services/tenantAdmin.ts` 已补积分任务/兑换配置和记录 service,页面 UI 待接 |
|
| 营销中心 | `apps/taro/src/pages/tenant-admin/marketing/index.tsx` | `tenant-admin/coupons`、`code-batches`、`activation-codes`、`crm/config`、`crm/queue`、`commission/settings`、`member-rate`、`summary`、`orders`、`settlements`、`settlements/generate`、`settlements/status`、`tenant-admin/user-notifications`;`src/services/tenantAdmin.ts` 已补积分任务/兑换配置和记录、用户通知查看 service,页面 UI 待接 |
|
||||||
| 财务运营 | `apps/taro/src/pages/tenant-admin/finance/index.tsx` | `commerce/refunds`、`commerce/refunds/status`、`commerce/operations/anomalies`、`commerce/reconciliation/batches`、`commerce/reconciliation/items`、`commerce/reconciliation/issues/create`、`commerce/reconciliation/issues`、`commerce/reconciliation/issues/status`、`commerce/reconciliation/provider-bills/request`、`commerce/reconciliation/provider-bills/jobs`、`commerce/adjustment-vouchers`、`commerce/adjustment-vouchers/status`、`commerce/adjustment-vouchers/report` |
|
| 财务运营 | `apps/taro/src/pages/tenant-admin/finance/index.tsx` | `commerce/refunds`、`commerce/refunds/status`、`commerce/operations/anomalies`、`commerce/reconciliation/batches`、`commerce/reconciliation/items`、`commerce/reconciliation/issues/create`、`commerce/reconciliation/issues`、`commerce/reconciliation/issues/status`、`commerce/reconciliation/provider-bills/request`、`commerce/reconciliation/provider-bills/jobs`、`commerce/adjustment-vouchers`、`commerce/adjustment-vouchers/status`、`commerce/adjustment-vouchers/report` |
|
||||||
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`theme-templates`、`theme`、`theme/preview`、`theme/publish`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
| 租户设置 | `apps/taro/src/pages/tenant-admin/settings/index.tsx` | `tenant-admin/overview`、`domains`、`payment-accounts`、`auth-providers`、`theme-templates`、`theme`、`theme/preview`、`theme/publish`、`permissions`、`GET/PUT role-templates`、`POST role-templates/disable`、`GET/PUT members`、`POST members/disable` |
|
||||||
|
|
||||||
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款,积分任务/兑换 service 已具备但页面 UI 待接;财务运营页支持退款状态流、官方账单任务、对账异常、差错工单和调整凭证,且只通过后端命令层写审计与状态;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补积分任务/兑换操作台、更精细的学生导入模板体验、更细数据范围 UI、主题素材库、真实打款 provider、发票、真实生产账单抽样验收和小程序端兼容。
|
当前租户后台已有第一批运营操作:工作台按权限矩阵隐藏不可见模块;学生运营页支持学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进;题库内容页支持公共题库采纳/同步、同步通知查看与已读/忽略、同步冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步 job 轮询、导入问题查看、字段映射/模板预览/下载、JSON/CSV/Excel 导入预览和执行、字段别名覆盖和导入后复检详情,也可接 JSON/试卷 payload 同步导出与 PDF/Word 异步导出 job 轮询,完成后用 `assetId` 走后台资源签名下载/预览;营销中心支持 CRM 配置保存、队列按状态查看、分佣规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回和标记线下打款,积分任务/兑换和用户通知查看 service 已具备但页面 UI 待接;财务运营页支持退款状态流、官方账单任务、对账异常、差错工单和调整凭证,且只通过后端命令层写审计与状态;租户设置页支持主题模板选择、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围配置、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖。下一批需要继续补积分任务/兑换操作台、用户通知查看页、更精细的学生导入模板体验、更细数据范围 UI、主题素材库、真实打款 provider、发票、真实生产账单抽样验收和小程序端兼容。
|
||||||
|
|
||||||
## 已落地的 Taro 平台后台页面
|
## 已落地的 Taro 平台后台页面
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 |
|
| 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 |
|
||||||
| 题目视频讲解 | 已建 `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 日答题榜当前排名、激活码和订单入口第一版;Taro service 已补积分任务/兑换;头像上传、账号合并、更细学习建议待补 |
|
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges`、积分任务/兑换表、`user_notifications` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、积分任务、积分兑换、题目反馈、考试倒计时、勋章和站内通知 API 已实现 | API 集成测试、Taro 类型检查 | 学生端个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、激活码和订单入口第一版;Taro service 已补积分任务/兑换/站内通知;头像上传、账号合并、更细学习建议和消息中心 UI 待补 |
|
||||||
| 活动/优惠 | 已建优惠券、激活码、激活码批次、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 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 |
|
||||||
@@ -295,7 +295,7 @@ platform-admin:
|
|||||||
|
|
||||||
1. 正式鉴权:API 已支持 Supabase Auth JWT;生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id`、`x-platform-admin-key` 兼容入口。
|
1. 正式鉴权:API 已支持 Supabase Auth JWT;生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id`、`x-platform-admin-key` 兼容入口。
|
||||||
2. 国内能力接入:短信、微信小程序登录、微信网页登录、QQ 登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备;资金对账、支付/退款补偿和差错工单已完成基础闭环,真实生产账号联调和官方账单下载仍需继续完善。
|
2. 国内能力接入:短信、微信小程序登录、微信网页登录、QQ 登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备;资金对账、支付/退款补偿和差错工单已完成基础闭环,真实生产账号联调和官方账单下载仍需继续完善。
|
||||||
3. 核心缺口 API:学生端个人中心、手机号绑定/换绑、分数线、题目视频详情、背单词进度/收藏、签到积分、题目反馈和勋章已补基础 API;下一步重点是学习报告可视化、后台统计和真实业务验收。
|
3. 核心缺口 API:学生端个人中心、手机号绑定/换绑、分数线、题目视频详情、背单词进度/收藏、签到积分、题目反馈、勋章和站内通知已补基础 API;下一步重点是学习报告可视化、消息中心 UI、后台统计和真实业务验收。
|
||||||
4. 后台能力:题库录入、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步批量导入、导入后复检、模板下载/字段映射 API、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;前端操作台待补。
|
4. 后台能力:题库录入、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步批量导入、导入后复检、模板下载/字段映射 API、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;前端操作台待补。
|
||||||
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、退款、支付补偿、资金对账、差错工单、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、官方账单下载和前端端到端测试。
|
5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、退款、支付补偿、资金对账、差错工单、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、官方账单下载和前端端到端测试。
|
||||||
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。
|
6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。
|
||||||
@@ -309,5 +309,5 @@ platform-admin:
|
|||||||
2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。
|
2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。
|
||||||
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
||||||
4. 补视频商用控制:深度防盗链、转码级水印和播放统计。
|
4. 补视频商用控制:深度防盗链、转码级水印和播放统计。
|
||||||
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、连续签到奖励配置、积分风控报表和更多学习行为触发勋章。
|
5. 补 AI 择校推荐报告、排行榜防刷/预聚合、连续签到奖励配置、积分风控报表、外部订阅消息/短信和更多学习行为触发勋章。
|
||||||
6. 接真实短信/OAuth 生产账号、真实生产账单格式验收和异常订单运营台,并继续推进 Taro scaffold。
|
6. 接真实短信/OAuth 生产账号、真实生产账单格式验收和异常订单运营台,并继续推进 Taro scaffold。
|
||||||
|
|||||||
@@ -31,10 +31,10 @@
|
|||||||
| 知识手册 | `Handbook*.tsx` | 已覆盖 | Taro 已接手册科目/章节/条目、RichContent 安全渲染、H5 KaTeX 公式渲染和私有资源 ID 短签名地基第一版;后续补搜索、高亮、目录锚点、小程序公式真机验收和图片资源字段化 |
|
| 知识手册 | `Handbook*.tsx` | 已覆盖 | Taro 已接手册科目/章节/条目、RichContent 安全渲染、H5 KaTeX 公式渲染和私有资源 ID 短签名地基第一版;后续补搜索、高亮、目录锚点、小程序公式真机验收和图片资源字段化 |
|
||||||
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 |
|
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 |
|
||||||
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、微信/支付宝官方账单下载 worker、异常查询、差错工单和事件轨迹、异常订单运营台、人工调整凭证复核报表已有;Taro 学生收银台/订单详情和租户财务运营台第一版已接;仍缺更完整售后体验、真实生产账单抽样验收和生产账号联调 |
|
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐、订单、订单详情/状态轮询、权益、激活码预检查/兑换、优惠券领取/下单抵扣、微信支付/支付宝 provider 主链路、内部退款状态机、微信/支付宝发起退款、退款查询确认、退款通知 webhook、支付/退款补偿 worker、全额退款权益撤销、资金对账手工/API 导入比对、微信/支付宝官方账单下载 worker、异常查询、差错工单和事件轨迹、异常订单运营台、人工调整凭证复核报表已有;Taro 学生收银台/订单详情和租户财务运营台第一版已接;仍缺更完整售后体验、真实生产账单抽样验收和生产账号联调 |
|
||||||
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、积分活动任务、积分兑换、考试倒计时、趋势、勋章展示 API 已有;Taro service 已补任务/兑换接口,页面 UI 待接;继续补头像上传、账号合并和更细学习建议 |
|
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、手机号绑定/换绑、权益、订单统计、练习历史、学习统计、签到积分、积分活动任务、积分兑换、考试倒计时、趋势、勋章展示和站内通知 API 已有;Taro service 已补任务/兑换/通知接口,页面 UI 待接;继续补头像上传、账号合并和更细学习建议 |
|
||||||
| 资料下载 | `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 有基础;后台配置体验待补 |
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
| 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 |
|
| 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 |
|
||||||
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
|
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 |
|
||||||
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
|
| 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 |
|
||||||
|
| 用户站内通知 | 部分覆盖 | 反馈处理、反馈奖励、勋章发放和积分兑换会写入 `user_notifications`,学生可查/标记状态,租户后台可按权限查看租户内通知 | 前端消息中心、租户后台通知页、外部微信订阅消息/短信和批量统计待补 |
|
||||||
| CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、钉钉/飞书/企微 worker、签名和重试已有;富卡片模板、失败告警、死信运营台和批量推送待补 |
|
| CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、钉钉/飞书/企微 worker、签名和重试已有;富卡片模板、失败告警、死信运营台和批量推送待补 |
|
||||||
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有;租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 |
|
| 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有;租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 |
|
||||||
|
|
||||||
@@ -103,9 +104,9 @@
|
|||||||
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
|
6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。
|
||||||
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:前端联调到云端前
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
- Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。
|
- Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。
|
||||||
- Node.js API 分层:`core/features`。
|
- Node.js API 分层:`core/features`。
|
||||||
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章。
|
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。
|
||||||
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
||||||
- 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。
|
- 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。
|
||||||
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。
|
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。
|
||||||
@@ -22,8 +22,9 @@
|
|||||||
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||||
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。
|
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。
|
||||||
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。
|
- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。
|
||||||
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决和积分活动任务自动发放第一阶段已完成并纳入 API 集成测试;后续补练习次数、单词掌握、模考成绩和发放通知。
|
- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决和积分活动任务自动发放第一阶段已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。后续补练习次数、单词掌握、模考成绩触发和前端运营配置体验。
|
||||||
- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;后续补连续签到奖励配置、积分风控报表和前端操作台。
|
- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;兑换完成或待履约会写入用户站内通知。后续补连续签到奖励配置、积分风控报表和前端操作台。
|
||||||
|
- 用户站内通知第一版已完成:反馈处理、反馈奖励、勋章发放、积分兑换会创建 `user_notifications`;学生端可查询/标记状态,租户后台具备 `notifications:read` 权限的成员可查看租户内通知。后续补 Taro 消息中心 UI、租户后台通知页、外部微信订阅消息/短信和批量统计。
|
||||||
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
||||||
- 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。
|
- 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。
|
||||||
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
|
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
|
||||||
@@ -129,7 +130,7 @@
|
|||||||
9. 积分和反馈增强
|
9. 积分和反馈增强
|
||||||
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
|
||||||
- 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决自动发放第一阶段。
|
- 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决自动发放第一阶段。
|
||||||
- 积分兑换、活动任务和优惠券兑换履约第一阶段已完成;继续补连续签到奖励配置、练习/单词/模考触发勋章、积分风控报表、处理通知和反馈聚合统计。
|
- 积分兑换、活动任务、优惠券兑换履约和站内通知后端第一阶段已完成;继续补连续签到奖励配置、练习/单词/模考触发勋章、积分风控报表、前端消息中心、外部订阅消息/短信和反馈聚合统计。
|
||||||
|
|
||||||
10. 数据看板
|
10. 数据看板
|
||||||
- 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。
|
- 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。
|
||||||
@@ -184,7 +185,7 @@
|
|||||||
- H5 和小程序共用同一套业务 API client。
|
- H5 和小程序共用同一套业务 API client。
|
||||||
- 租户通过域名、小程序配置或启动参数解析。
|
- 租户通过域名、小程序配置或启动参数解析。
|
||||||
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。
|
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。
|
||||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版,积分任务/兑换 service 已补;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定和 CRM/分佣操作台第一版,积分任务/兑换 service 已补;下一步补小程序公式真机验收、题图资源字段化、个人中心积分任务/兑换 UI、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版,积分任务/兑换/站内通知 service 已补;平台后台已接入创建租户、状态变更、订阅、账单、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定和 CRM/分佣操作台第一版,积分任务/兑换/用户通知查看 service 已补;下一步补小程序公式真机验收、题图资源字段化、个人中心积分任务/兑换 UI、消息中心 UI、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
||||||
|
|
||||||
### 第一批页面
|
### 第一批页面
|
||||||
|
|
||||||
@@ -222,7 +223,7 @@
|
|||||||
|
|
||||||
8. 个人中心
|
8. 个人中心
|
||||||
- 会员权益、订单、激活码兑换
|
- 会员权益、订单、激活码兑换
|
||||||
- 错题本、收藏夹、学习统计、排行榜、勋章
|
- 错题本、收藏夹、学习统计、排行榜、勋章、站内通知
|
||||||
- Taro 已接学习报告、14 天趋势、题型表现、最近练习和 7 日答题榜当前排名第一版
|
- Taro 已接学习报告、14 天趋势、题型表现、最近练习和 7 日答题榜当前排名第一版
|
||||||
- 会员收银台、优惠券领取、下单、支付参数、订单详情、状态轮询和售后入口已接第一版
|
- 会员收银台、优惠券领取、下单、支付参数、订单详情、状态轮询和售后入口已接第一版
|
||||||
|
|
||||||
@@ -236,7 +237,7 @@
|
|||||||
## 推荐下一步顺序
|
## 推荐下一步顺序
|
||||||
|
|
||||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。
|
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。
|
||||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版,积分任务/兑换 service 已补;继续补小程序公式真机验收、题图资源字段化、个人中心积分任务/兑换 UI、背单词更细统计、小程序支付容器、分享场景和状态管理。
|
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版,积分任务/兑换/站内通知 service 已补;继续补小程序公式真机验收、题图资源字段化、个人中心积分任务/兑换 UI、消息中心 UI、背单词更细统计、小程序支付容器、分享场景和状态管理。
|
||||||
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
|
||||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||||
5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。
|
5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ tenant:<tenantId>:theme
|
|||||||
| 商城/收银台 | `/api/catalog/svip-plans`、`POST /api/commerce/coupons/claim`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` |
|
| 商城/收银台 | `/api/catalog/svip-plans`、`POST /api/commerce/coupons/claim`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` |
|
||||||
| 订单/权益 | `/api/commerce/orders`、`/api/commerce/orders/detail`、`/api/commerce/orders/status`、`/api/commerce/entitlements` |
|
| 订单/权益 | `/api/commerce/orders`、`/api/commerce/orders/detail`、`/api/commerce/orders/status`、`/api/commerce/entitlements` |
|
||||||
| 激活码 | `POST /api/commerce/activation-codes/check`、`POST /api/commerce/activation-codes/redeem` |
|
| 激活码 | `POST /api/commerce/activation-codes/check`、`POST /api/commerce/activation-codes/redeem` |
|
||||||
| 个人中心 | `GET/PATCH /api/profile/me`、`POST /api/profile/check-in`、`GET /api/profile/score-events`、`GET /api/profile/exam-countdowns`、`GET /api/profile/badges`、`GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem` |
|
| 个人中心 | `GET/PATCH /api/profile/me`、`POST /api/profile/check-in`、`GET /api/profile/score-events`、`GET /api/profile/exam-countdowns`、`GET /api/profile/badges`、`GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem`、`GET /api/profile/notifications`、`POST /api/profile/notifications/status` |
|
||||||
| 销售分享 | `/api/referral/resolve`、`track-event`、`bind` |
|
| 销售分享 | `/api/referral/resolve`、`track-event`、`bind` |
|
||||||
| 租户数据看板 | `GET /api/tenant-admin/dashboard?timeRange=30d®ionId=...` |
|
| 租户数据看板 | `GET /api/tenant-admin/dashboard?timeRange=30d®ionId=...` |
|
||||||
| 租户主题模板 | `GET /api/tenant-admin/theme-templates`、`GET /api/tenant-admin/theme`、`POST /api/tenant-admin/theme/preview`、`POST /api/tenant-admin/theme/publish` |
|
| 租户主题模板 | `GET /api/tenant-admin/theme-templates`、`GET /api/tenant-admin/theme`、`POST /api/tenant-admin/theme/preview`、`POST /api/tenant-admin/theme/publish` |
|
||||||
@@ -1205,7 +1205,70 @@ POST /api/profile/exchange-items/redeem
|
|||||||
|
|
||||||
- `questionId` 如存在,后端会校验题目必须属于当前租户。
|
- `questionId` 如存在,后端会校验题目必须属于当前租户。
|
||||||
- 反馈状态由租户后台处理,学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。
|
- 反馈状态由租户后台处理,学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。
|
||||||
- 租户后台处理反馈时,奖励积分由后端 `idempotency_key` 保证不会重复发放,前端不要重复叠加;状态处理响应可能返回 `autoBadges`,后台或学生消息中心后续可据此做获得勋章提醒。
|
- 租户后台处理反馈时,奖励积分由后端 `idempotency_key` 保证不会重复发放,前端不要重复叠加;状态处理响应可能返回 `autoBadges`。反馈状态、反馈奖励和自动勋章提醒会由后端写入学生站内通知,前端消息中心只负责展示和标记状态。
|
||||||
|
|
||||||
|
## 用户站内通知
|
||||||
|
|
||||||
|
站内通知用于承接反馈处理、反馈奖励、勋章发放、积分兑换完成或待履约等学生可见事件。它不是权限来源,也不替代订单、权益、积分或勋章接口的真实状态。
|
||||||
|
|
||||||
|
学生端消息中心:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/profile/notifications?status=unread&limit=50
|
||||||
|
GET /api/profile/notifications?notificationType=badge_granted
|
||||||
|
POST /api/profile/notifications/status
|
||||||
|
```
|
||||||
|
|
||||||
|
状态更新请求:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"notificationIds": ["<notification-id>"],
|
||||||
|
"status": "read | dismissed | archived"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回项包含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "<uuid>",
|
||||||
|
"notificationType": "badge_granted",
|
||||||
|
"status": "unread",
|
||||||
|
"severity": "success",
|
||||||
|
"title": "获得新勋章",
|
||||||
|
"message": "你获得了「连续签到」勋章。",
|
||||||
|
"actionLabel": "查看勋章",
|
||||||
|
"actionPath": "/student/profile?tab=badges",
|
||||||
|
"sourceType": "badges",
|
||||||
|
"sourceId": "<uuid>",
|
||||||
|
"metadata": {},
|
||||||
|
"readAt": null,
|
||||||
|
"createdAt": "2026-06-29T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"unread": 1,
|
||||||
|
"read": 0,
|
||||||
|
"dismissed": 0,
|
||||||
|
"archived": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
租户后台通知查看:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/tenant-admin/user-notifications?status=unread&limit=100
|
||||||
|
GET /api/tenant-admin/user-notifications?userId=<student-id>¬ificationType=point_exchange_pending_fulfillment
|
||||||
|
```
|
||||||
|
|
||||||
|
- 租户后台需要 `notifications:read` 权限。
|
||||||
|
- 后台只能查看租户内用户通知,不代学生改已读状态。
|
||||||
|
- 前端可以按 `actionPath` 做站内跳转,但跳转后的页面仍要重新请求对应业务接口,不能信任通知 metadata 作为最终业务数据。
|
||||||
|
- 当前支持的通知类型包括 `feedback_status_updated`、`feedback_reward_granted`、`badge_granted`、`point_exchange_completed`、`point_exchange_pending_fulfillment`。
|
||||||
|
|
||||||
## 题库新模型接入方式
|
## 题库新模型接入方式
|
||||||
|
|
||||||
|
|||||||
@@ -1774,6 +1774,38 @@ async function testProfile() {
|
|||||||
'profile badges should expose auto score badge as unlocked',
|
'profile badges should expose auto score badge as unlocked',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const badgeNotifications = await request('/api/profile/notifications', {
|
||||||
|
query: { notificationType: 'badge_granted', status: 'unread', limit: 20 },
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
badgeNotifications.items?.some(item => item.metadata?.badgeId === ids.tenantCheckInBadge),
|
||||||
|
'auto badge grant should create unread student notification',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
badgeNotifications.summary?.unread >= 1,
|
||||||
|
true,
|
||||||
|
'profile notification summary should count unread notifications',
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstBadgeNotification = badgeNotifications.items?.find(item => item.metadata?.badgeId === ids.tenantCheckInBadge);
|
||||||
|
const markedNotification = await request('/api/profile/notifications/status', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
notificationIds: [firstBadgeNotification.id],
|
||||||
|
status: 'read',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(markedNotification.item?.updatedCount, 1, 'student should mark own notification as read');
|
||||||
|
|
||||||
|
const otherUserNotifications = await request('/api/profile/notifications', {
|
||||||
|
userId: SECOND_STUDENT_USER_ID,
|
||||||
|
query: { notificationType: 'badge_granted', limit: 20 },
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
!otherUserNotifications.items?.some(item => item.metadata?.badgeId === ids.tenantCheckInBadge),
|
||||||
|
'another student must not see current student notifications',
|
||||||
|
);
|
||||||
|
|
||||||
const feedback = await request('/api/profile/feedbacks', {
|
const feedback = await request('/api/profile/feedbacks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
@@ -6572,6 +6604,16 @@ async function testTenantAdminOps() {
|
|||||||
assert.equal(grant.item?.badgeId, ids.tenantBadge, 'tenant admin should grant badge');
|
assert.equal(grant.item?.badgeId, ids.tenantBadge, 'tenant admin should grant badge');
|
||||||
assert.equal(grant.item?.userId, USER_ID, 'badge grant should target student');
|
assert.equal(grant.item?.userId, USER_ID, 'badge grant should target student');
|
||||||
|
|
||||||
|
const manualBadgeNotifications = await request('/api/profile/notifications', {
|
||||||
|
query: { notificationType: 'badge_granted', limit: 50 },
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
manualBadgeNotifications.items?.some(
|
||||||
|
item => item.metadata?.source === 'tenant_admin_badge_grant' && item.metadata?.badgeId === ids.tenantBadge,
|
||||||
|
),
|
||||||
|
'manual badge grant should create a student notification',
|
||||||
|
);
|
||||||
|
|
||||||
const repeatedGrant = await request('/api/tenant-admin/badge-grants', {
|
const repeatedGrant = await request('/api/tenant-admin/badge-grants', {
|
||||||
userId: TENANT_ADMIN_USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -6684,6 +6726,38 @@ async function testTenantAdminOps() {
|
|||||||
'resolved feedback should auto grant feedback badge',
|
'resolved feedback should auto grant feedback badge',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const feedbackNotifications = await request('/api/profile/notifications', {
|
||||||
|
query: { limit: 80 },
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
feedbackNotifications.items?.some(
|
||||||
|
item => item.notificationType === 'feedback_status_updated' && item.sourceId === pendingFeedback.id && item.status === 'unread',
|
||||||
|
),
|
||||||
|
'feedback status update should create unread student notification',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
feedbackNotifications.items?.some(
|
||||||
|
item => item.notificationType === 'feedback_reward_granted' && item.sourceId === pendingFeedback.id,
|
||||||
|
),
|
||||||
|
'feedback reward should create student reward notification',
|
||||||
|
);
|
||||||
|
|
||||||
|
const tenantUserNotifications = await request('/api/tenant-admin/user-notifications', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
query: { userId: USER_ID, notificationType: 'feedback_status_updated', limit: 20 },
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
tenantUserNotifications.items?.some(item => item.sourceId === pendingFeedback.id && item.userId === USER_ID),
|
||||||
|
'tenant admin should inspect student notifications with permission',
|
||||||
|
);
|
||||||
|
|
||||||
|
const tenantNotificationDenied = await request('/api/tenant-admin/user-notifications', {
|
||||||
|
userId: TENANT_TEACHER_USER_ID,
|
||||||
|
query: { userId: USER_ID, limit: 20 },
|
||||||
|
expectStatus: 403,
|
||||||
|
});
|
||||||
|
assert.equal(tenantNotificationDenied.code, 'TENANT_PERMISSION_REQUIRED', 'tenant user notification list requires explicit permission');
|
||||||
|
|
||||||
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(
|
||||||
item => item.eventType === 'feedback_reward' && item.sourceId === pendingFeedback.id,
|
item => item.eventType === 'feedback_reward' && item.sourceId === pendingFeedback.id,
|
||||||
@@ -6976,6 +7050,14 @@ async function testPointActivitiesAndExchange() {
|
|||||||
assert.equal(redeemed.item?.order?.itemId, ids.pointExchangeItem, 'exchange order should bind item');
|
assert.equal(redeemed.item?.order?.itemId, ids.pointExchangeItem, 'exchange order should bind item');
|
||||||
assert.ok(redeemed.item?.couponRedemption?.id, 'coupon exchange should create coupon redemption');
|
assert.ok(redeemed.item?.couponRedemption?.id, 'coupon exchange should create coupon redemption');
|
||||||
|
|
||||||
|
const exchangeNotifications = await request('/api/profile/notifications', {
|
||||||
|
query: { notificationType: 'point_exchange_completed', status: 'unread', limit: 20 },
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
exchangeNotifications.items?.some(item => item.sourceId === redeemed.item?.order?.id && item.metadata?.itemId === ids.pointExchangeItem),
|
||||||
|
'completed point exchange should create unread student notification',
|
||||||
|
);
|
||||||
|
|
||||||
const repeatedRedeem = await request('/api/profile/exchange-items/redeem', {
|
const repeatedRedeem = await request('/api/profile/exchange-items/redeem', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
@@ -7055,6 +7137,7 @@ async function testTenantMemberPermissionsAndAudit() {
|
|||||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:write'), 'permission matrix should expose marketing permission');
|
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:write'), 'permission matrix should expose marketing permission');
|
||||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:points:read'), 'permission matrix should expose point marketing read permission');
|
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:points:read'), 'permission matrix should expose point marketing read permission');
|
||||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:points:write'), 'permission matrix should expose point marketing write permission');
|
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'marketing:points:write'), 'permission matrix should expose point marketing write permission');
|
||||||
|
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'notifications:read'), 'permission matrix should expose user notification read permission');
|
||||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'dashboard:read'), 'permission matrix should expose dashboard read permission');
|
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'dashboard:read'), 'permission matrix should expose dashboard read permission');
|
||||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission');
|
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'roles:write'), 'permission matrix should expose role template permission');
|
||||||
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'badges:grant'), 'permission matrix should expose badge grant permission');
|
assert.ok(permissionMatrix.permissions?.some(item => item.key === 'badges:grant'), 'permission matrix should expose badge grant permission');
|
||||||
|
|||||||
@@ -199,6 +199,24 @@ async function main() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
delete from public.user_notifications
|
||||||
|
where tenant_id = $1
|
||||||
|
and (
|
||||||
|
user_id in ($2::uuid, $3::uuid)
|
||||||
|
or dedupe_key like any($4::text[])
|
||||||
|
or source_type in ('reports', 'user_badges', 'user_point_exchange_orders')
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
ids.user,
|
||||||
|
ids.secondStudentUser,
|
||||||
|
['feedback_%', 'badge:%', 'point_exchange:%'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
delete from public.report_status_events
|
delete from public.report_status_events
|
||||||
@@ -339,12 +357,13 @@ async function main() {
|
|||||||
tenantId,
|
tenantId,
|
||||||
ids.user,
|
ids.user,
|
||||||
ids.secondStudentUser,
|
ids.secondStudentUser,
|
||||||
[ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge],
|
[ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge, ids.tenantActivityBadge],
|
||||||
[
|
[
|
||||||
`badge:${ids.tenantBadge}:user:${ids.user}`,
|
`badge:${ids.tenantBadge}:user:${ids.user}`,
|
||||||
`auto:${ids.tenantCheckInBadge}:user:${ids.user}`,
|
`auto:${ids.tenantCheckInBadge}:user:${ids.user}`,
|
||||||
`auto:${ids.tenantScoreBadge}:user:${ids.user}`,
|
`auto:${ids.tenantScoreBadge}:user:${ids.user}`,
|
||||||
`auto:${ids.tenantFeedbackBadge}:user:${ids.user}`,
|
`auto:${ids.tenantFeedbackBadge}:user:${ids.user}`,
|
||||||
|
`auto:${ids.tenantActivityBadge}:user:${ids.user}`,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -358,7 +377,7 @@ async function main() {
|
|||||||
or coalesce(legacy_id, '') like 'integration-badge-%'
|
or coalesce(legacy_id, '') like 'integration-badge-%'
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
[tenantId, [ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge]],
|
[tenantId, [ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge, ids.tenantActivityBadge]],
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|||||||
64
supabase/migrations/202606290034_user_notifications.sql
Normal file
64
supabase/migrations/202606290034_user_notifications.sql
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
create table if not exists public.user_notifications (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||||
|
user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||||
|
notification_type text not null,
|
||||||
|
status text not null default 'unread'
|
||||||
|
check (status in ('unread', 'read', 'dismissed', 'archived')),
|
||||||
|
severity text not null default 'info'
|
||||||
|
check (severity in ('info', 'success', 'warning', 'error')),
|
||||||
|
title text not null,
|
||||||
|
message text not null,
|
||||||
|
action_label text,
|
||||||
|
action_path text,
|
||||||
|
source_type text,
|
||||||
|
source_id uuid,
|
||||||
|
dedupe_key text,
|
||||||
|
metadata jsonb not null default '{}'::jsonb,
|
||||||
|
created_by uuid references public.platform_users(id) on delete set null,
|
||||||
|
read_at timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table public.user_notifications is
|
||||||
|
'Tenant-scoped in-app notification inbox for students and tenant members. Business services create records; frontends only display and mark status through API.';
|
||||||
|
|
||||||
|
create unique index if not exists idx_user_notifications_dedupe
|
||||||
|
on public.user_notifications(tenant_id, user_id, notification_type, dedupe_key)
|
||||||
|
where dedupe_key is not null;
|
||||||
|
|
||||||
|
create index if not exists idx_user_notifications_user_status
|
||||||
|
on public.user_notifications(tenant_id, user_id, status, created_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_user_notifications_source
|
||||||
|
on public.user_notifications(tenant_id, source_type, source_id);
|
||||||
|
|
||||||
|
alter table public.user_notifications enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists user_notifications_owner_select on public.user_notifications;
|
||||||
|
create policy user_notifications_owner_select on public.user_notifications
|
||||||
|
for select
|
||||||
|
using (
|
||||||
|
tenant_id = app.current_tenant_id()
|
||||||
|
and (
|
||||||
|
app.is_platform_admin()
|
||||||
|
or exists (
|
||||||
|
select 1
|
||||||
|
from public.platform_users pu
|
||||||
|
where pu.id = user_notifications.user_id
|
||||||
|
and pu.auth_user_id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists user_notifications_platform_admin_all on public.user_notifications;
|
||||||
|
create policy user_notifications_platform_admin_all on public.user_notifications
|
||||||
|
for all
|
||||||
|
using (app.is_platform_admin())
|
||||||
|
with check (app.is_platform_admin());
|
||||||
|
|
||||||
|
drop trigger if exists set_updated_at on public.user_notifications;
|
||||||
|
create trigger set_updated_at
|
||||||
|
before update on public.user_notifications
|
||||||
|
for each row execute function app.touch_updated_at();
|
||||||
Reference in New Issue
Block a user