feat: add user notification center

This commit is contained in:
Codex
2026-06-30 03:56:41 +08:00
parent 2fea68fe4d
commit d72ff499ce
24 changed files with 895 additions and 44 deletions

View File

@@ -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、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。

View 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;
}

View File

@@ -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: {

View File

@@ -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],

View 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),
},
};
}

View File

@@ -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],

View File

@@ -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: '勋章发放' },

View File

@@ -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],

View 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)])),
};
}

View File

@@ -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,

View File

@@ -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];
}); });

View File

@@ -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');
} }

View File

@@ -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';

View File

@@ -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 套餐、指定租户或全部活跃租户披露平台公共题库 |

View File

@@ -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商用收费和运营能力

View File

@@ -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/VaultAPI 只返回 `secretRef` 和掩码状态。 - 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/VaultAPI 只返回 `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` 的租户成员可手动强制补绑。

View File

@@ -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 识别出的公式 tokenH5 端用 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 识别出的公式 tokenH5 端用 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 平台后台页面

View File

@@ -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 资料下载、安全扫描门禁、水印 traceIdassets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closedexports worker 测试Taro 类型检查覆盖学生资料水印预览/下载确认 | 资料资源基础闭环可跑Taro 学生资料页已接短期签名、可见水印覆盖、追踪码展示和强制水印资源外部打开限制第一版;真实 AV/内容安全服务联调、CDN 防盗链、转码/CDN 级水印待补 | | 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、资源访问审计、动态水印上下文、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 自动发布可信资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载、安全扫描门禁、水印 traceIdassets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closedexports 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。

View File

@@ -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前端联调到云端前

View File

@@ -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、导入演练、校验和抽样验收。

View File

@@ -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&regionId=...` | | 租户数据看板 | `GET /api/tenant-admin/dashboard?timeRange=30d&regionId=...` |
| 租户主题模板 | `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>&notificationType=point_exchange_pending_fulfillment
```
- 租户后台需要 `notifications:read` 权限。
- 后台只能查看租户内用户通知,不代学生改已读状态。
- 前端可以按 `actionPath` 做站内跳转,但跳转后的页面仍要重新请求对应业务接口,不能信任通知 metadata 作为最终业务数据。
- 当前支持的通知类型包括 `feedback_status_updated``feedback_reward_granted``badge_granted``point_exchange_completed``point_exchange_pending_fulfillment`
## 题库新模型接入方式 ## 题库新模型接入方式

View File

@@ -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');

View File

@@ -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(

View 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();