diff --git a/README.md b/README.md index 6f05c60c..86acd3ed 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ - Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。 - `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 -- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 +- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 -- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果。 +- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。 @@ -34,7 +34,7 @@ - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 -- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约和租户后台配置第一阶段已完成。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、发放通知、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。 +- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。 - `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习和 7 日答题榜当前排名第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 @@ -430,7 +430,7 @@ git diff --check 优先继续补: 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 -2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、个人中心积分任务/兑换 UI、背单词更细统计、小程序支付与分享,租户后台积分任务/兑换操作台、更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台租户详情/审计/自动计费增强,小程序兼容验证。 +2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、个人中心积分任务/兑换 UI、用户消息中心 UI、背单词更细统计、小程序支付与分享,租户后台积分任务/兑换操作台、用户通知查看页、更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台租户详情/审计/自动计费增强,小程序兼容验证。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/notifications/service.ts b/apps/api/src/features/notifications/service.ts new file mode 100644 index 00000000..a25c8fa9 --- /dev/null +++ b/apps/api/src/features/notifications/service.ts @@ -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; + 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; + 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 { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +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, + input: CreateUserNotificationInput, +): Promise { + 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( + ` + 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; +} diff --git a/apps/api/src/features/profile/badges.ts b/apps/api/src/features/profile/badges.ts index c6cb60f2..af0b18d6 100644 --- a/apps/api/src/features/profile/badges.ts +++ b/apps/api/src/features/profile/badges.ts @@ -1,4 +1,5 @@ import type pg from 'pg'; +import { createUserNotification } from '../notifications/service.js'; type JsonMap = Record; @@ -212,6 +213,28 @@ export async function autoGrantBadges( ); 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({ ...result.rows[0], badge: { diff --git a/apps/api/src/features/profile/index.ts b/apps/api/src/features/profile/index.ts index 05188b71..b9781cae 100644 --- a/apps/api/src/features/profile/index.ts +++ b/apps/api/src/features/profile/index.ts @@ -15,6 +15,10 @@ import { exchangeItemsRoute, redeemExchangeItemRoute, } from './points.js'; +import { + profileNotificationsRoute, + updateProfileNotificationStatusRoute, +} from './notifications.js'; export const profileRoutes: RouteDefinition[] = [ ['GET', '/api/profile/me', profileMeRoute], @@ -25,6 +29,8 @@ export const profileRoutes: RouteDefinition[] = [ ['POST', '/api/profile/activity-tasks/claim', claimActivityTaskRoute], ['GET', '/api/profile/exchange-items', exchangeItemsRoute], ['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/feedbacks', feedbacksRoute], ['POST', '/api/profile/feedbacks', submitFeedbackRoute], diff --git a/apps/api/src/features/profile/notifications.ts b/apps/api/src/features/profile/notifications.ts new file mode 100644 index 00000000..bac915af --- /dev/null +++ b/apps/api/src/features/profile/notifications.ts @@ -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), + }, + }; +} diff --git a/apps/api/src/features/profile/points.ts b/apps/api/src/features/profile/points.ts index f4d6a4ce..a58ca268 100644 --- a/apps/api/src/features/profile/points.ts +++ b/apps/api/src/features/profile/points.ts @@ -4,6 +4,7 @@ import { HttpError, type RequestContext } from '../../core/http.js'; import { intParam, readJsonBody, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js'; import { query, transaction } from '../../core/db.js'; import { autoGrantBadges } from './badges.js'; +import { createUserNotification } from '../notifications/service.js'; type JsonMap = Record; @@ -771,6 +772,33 @@ export async function redeemExchangeItemRoute(ctx: RequestContext) { [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 { item: exchangeItem, order: order.rows[0], diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 5559ef5c..a23e51ef 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -13,7 +13,7 @@ const TENANT_ADMIN_ROLES = new Set([ const ROLE_PERMISSION_DEFAULTS: Record = { tenant_owner: ['*'], 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:*'], sales: ['codes:*', 'coupons:read', 'coupons:write', 'referral:*', 'commission:self'], agent: ['codes:read', 'coupons:read', 'referral:self', 'commission:self'], @@ -105,6 +105,7 @@ export function tenantPermissionCatalog() { { key: 'marketing:write', label: '活动内容管理' }, { key: 'marketing:points:read', label: '积分任务/兑换查看' }, { key: 'marketing:points:write', label: '积分任务/兑换管理' }, + { key: 'notifications:read', label: '用户站内通知查看' }, { key: 'badges:read', label: '勋章查看' }, { key: 'badges:write', label: '勋章管理' }, { key: 'badges:grant', label: '勋章发放' }, diff --git a/apps/api/src/features/tenant-admin/index.ts b/apps/api/src/features/tenant-admin/index.ts index 2484a362..fbe0acd8 100644 --- a/apps/api/src/features/tenant-admin/index.ts +++ b/apps/api/src/features/tenant-admin/index.ts @@ -18,6 +18,7 @@ import { upsertTenantStudentRoute, } from './classes.js'; import { tenantDashboardRoute } from './dashboard.js'; +import { tenantUserNotificationsRoute } from './notifications.js'; import { tenantExamDatesRoute, tenantFeedbackEventsRoute, @@ -101,6 +102,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [ ['GET', '/api/tenant-admin/teachers', tenantTeachersRoute], ['GET', '/api/tenant-admin/overview', tenantOverviewRoute], ['GET', '/api/tenant-admin/dashboard', tenantDashboardRoute], + ['GET', '/api/tenant-admin/user-notifications', tenantUserNotificationsRoute], ['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute], ['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute], ['GET', '/api/tenant-admin/theme-templates', tenantThemeTemplatesRoute], diff --git a/apps/api/src/features/tenant-admin/notifications.ts b/apps/api/src/features/tenant-admin/notifications.ts new file mode 100644 index 00000000..5f37364a --- /dev/null +++ b/apps/api/src/features/tenant-admin/notifications.ts @@ -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)])), + }; +} diff --git a/apps/api/src/features/tenant-admin/operations.ts b/apps/api/src/features/tenant-admin/operations.ts index 78b29938..170f31c4 100644 --- a/apps/api/src/features/tenant-admin/operations.ts +++ b/apps/api/src/features/tenant-admin/operations.ts @@ -3,6 +3,7 @@ import { HttpError, type RequestContext } from '../../core/http.js'; import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js'; import { query, transaction } from '../../core/db.js'; import { autoGrantBadges, type AutoBadgeGrant } from '../profile/badges.js'; +import { createUserNotification } from '../notifications/service.js'; import { requireTenantAdmin, requireTenantPermission, @@ -13,6 +14,13 @@ type JsonBody = Record; const REPORT_STATUSES = ['pending', 'accepted', 'rejected', 'resolved', 'closed']; const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent']; +const REPORT_STATUS_LABELS: Record = { + pending: '待处理', + accepted: '已受理', + rejected: '未采纳', + resolved: '已解决', + closed: '已关闭', +}; function nullableString(value: unknown) { 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; } +function feedbackNotificationSeverity(status: string) { + if (status === 'resolved') return 'success'; + if (status === 'rejected') return 'warning'; + return 'info'; +} + function optionalChoice(value: unknown, allowed: string[], fallback: string) { const candidate = nullableString(value) || fallback; 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, { fromStatus: current.rows[0].status, toStatus: nextStatus, diff --git a/apps/api/src/features/tenant-admin/routes.ts b/apps/api/src/features/tenant-admin/routes.ts index 5c0a9f5d..bd555c15 100644 --- a/apps/api/src/features/tenant-admin/routes.ts +++ b/apps/api/src/features/tenant-admin/routes.ts @@ -9,6 +9,7 @@ import { tenantPermissionCatalog, type TenantAdminAuth, } from './auth.js'; +import { createUserNotification } from '../notifications/service.js'; type JsonBody = Record; type SecretScope = 'payment' | 'sms' | 'oauth' | 'storage' | 'crm' | 'ai' | 'system'; @@ -1954,6 +1955,27 @@ export async function grantBadgeRoute(ctx: RequestContext) { 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]; }); diff --git a/apps/taro/src/services/profile.ts b/apps/taro/src/services/profile.ts index 16cab2d6..bddbb4db 100644 --- a/apps/taro/src/services/profile.ts +++ b/apps/taro/src/services/profile.ts @@ -66,6 +66,23 @@ export interface PointExchangeItem { 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; + readAt?: string | null; + createdAt?: string; + updatedAt?: string; +} + export async function loadProfile() { 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 }>('/api/profile/notifications', { + query: { ...query, limit: query.limit || 50 }, + }); +} + +export async function updateNotificationStatus(input: { + notificationIds: string[]; + status: 'read' | 'dismissed' | 'archived'; +}) { + return apiRequest<{ item?: Record }>('/api/profile/notifications/status', { + method: 'POST', + body: input, + }); +} + export async function loadBadges() { return apiRequest<{ items?: Record[] }>('/api/profile/badges'); } diff --git a/apps/taro/src/services/tenantAdmin.ts b/apps/taro/src/services/tenantAdmin.ts index f8b31454..5a7b4313 100644 --- a/apps/taro/src/services/tenantAdmin.ts +++ b/apps/taro/src/services/tenantAdmin.ts @@ -236,6 +236,29 @@ export interface TenantContentNotificationItem { 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; + createdBy?: string | null; + createdByName?: string | null; + readAt?: string | null; + createdAt?: string; + updatedAt?: string; +} + export interface ImportIssueItem { id: string; 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 }>('/api/tenant-admin/user-notifications', { + query: { ...input, limit: input.limit || 100 }, + }); +} + export async function updateTenantContentNotificationStatus(input: { notificationIds: string[]; status: 'read' | 'dismissed' | 'resolved'; diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 1a9d9a64..53ae1c57 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -66,10 +66,11 @@ | 错题复习计划 | 可联调 | `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/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` 规则勋章自动发放,重复签到不重复发放 | -| 积分活动/兑换 | 可联调 | `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/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/notifications`、`POST /api/profile/notifications/status`;学生只能查看和更新自己的通知,支持未读/已读/忽略/归档、类型筛选和状态汇总 | ## 背单词、知识手册、分数线、视频 @@ -136,13 +137,14 @@ | 密钥掩码/引用 | 迁移期 | API 有掩码,生产前要做 KMS/Vault 或 envelope encryption | | 活动、Banner、FAQ、公告 | 可联调 | `/api/tenant-admin/banners`、`faqs`、`announcements` | | 积分任务/兑换配置 | 可联调 | `/api/tenant-admin/point-activity-tasks`、`point-activity-claims`、`point-exchange-items`、`point-exchange-orders`;使用 `marketing:points:read/write` 或兼容 `marketing:read/write` 权限,支持任务配置、兑换商品配置、领取记录、兑换订单、审计、跨租户拒绝 | -| 勋章管理/发放 | 可联调 | `/api/tenant-admin/badges`、`/api/tenant-admin/badge-grants`;支持后台维护、同 `legacyId` 幂等更新、手动发放、重复发放幂等、租户隔离和权限点 `badges:read/write/grant`;`unlockType=check_in/score/feedback_resolved/activity_reward` 会由签到、积分奖励、反馈解决和活动任务事件自动发放 | +| 勋章管理/发放 | 可联调 | `/api/tenant-admin/badges`、`/api/tenant-admin/badge-grants`;支持后台维护、同 `legacyId` 幂等更新、手动发放、重复发放幂等、租户隔离和权限点 `badges:read/write/grant`;`unlockType=check_in/score/feedback_resolved/activity_reward` 会由签到、积分奖励、反馈解决和活动任务事件自动发放,并写入用户站内通知 | | 考试日期维护 | 可联调 | `/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/members`、`permissions`、`role-templates`、`audit-logs` | | 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 | | 学生批量运营 | 可联调 | `/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/*` | | 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 | | 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 2701a586..59c8ef66 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -10,7 +10,7 @@ - 平台侧可以管理租户、SaaS 套餐、订阅、账单、服务费和用量。 - 租户侧可以管理品牌、域名、支付账户、登录配置、私密密钥、活动、兑换码、优惠券、成员权限、审计日志、内容入口、分类树、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源和题库导出任务。 -- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放。 +- 学生侧已经有题库入口、分类树、题目集合、顺序/随机/全真模拟组卷、答题、错题、收藏、背单词进度、个人中心、站内通知、勋章、排行榜、分数线、视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载、AI 择校推荐、积分活动任务和积分兑换的基础 API;签到、积分阈值、反馈解决和积分活动可触发自动勋章发放,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。 - 销售/代理/CRM 已经有邀请码、扫码/分享事件、首绑客资保护、团队关系、统计、CRM 配置、入队、worker 推送和分佣结算基础闭环。 - 旧题库 JSON、单词模板、知识手册嵌套模板、分数线 JSON 和视频绑定 JSON 已经进入后端 preview/import 管线,由后端负责规范化、校验、幂等、审计和租户隔离。 @@ -22,7 +22,7 @@ | --- | --- | --- | --- | | 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 | | 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 自动计费、平台审计、更完整运营消息 | -| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算和积分任务/兑换 service 第一版 | 更细的数据范围组合、成员批量运营、积分任务/兑换操作台页面、真实打款/导出/凭证和完整权限菜单 | +| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算和积分任务/兑换/用户通知 service 第一版 | 更细的数据范围组合、成员批量运营、积分任务/兑换操作台页面、用户通知查看页、真实打款/导出/凭证和完整权限菜单 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 | | 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 | @@ -65,7 +65,7 @@ 5. 个人中心 - 调 `GET /api/profile/me`。 - - 接会员权益、订单详情/状态轮询、优惠券、激活码预检查/兑换、错题本、收藏夹、学习统计、排行榜、勋章、积分活动任务和积分兑换。 + - 接会员权益、订单详情/状态轮询、优惠券、激活码预检查/兑换、错题本、收藏夹、学习统计、排行榜、勋章、积分活动任务、积分兑换和站内通知。 6. 资料、视频和支付 - 资料下载、PDF 预览、视频播放必须先请求后端签名或权限检查。 @@ -79,7 +79,7 @@ - 对象存储:上传/下载签名已接入阿里云 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` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。 - 生产环境配置:`.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:商用收费和运营能力 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 39e44a97..ac7137d1 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -8,20 +8,20 @@ - `auth`:短信验证码登录、迁移期 session、微信小程序登录、OAuth provider 预留。 - `catalog`:公开题库、地区、内容入口、分类树、题目集合、练习蓝图、考试日期、手册、商品、SVIP 套餐、资料资源只读/下载接口。 - `learning`:顺序/随机/全真模拟组卷 session、答题记录、错题、收藏、背单词进度/收藏/统计、排行榜。 - - `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈、勋章。 + - `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈、勋章、站内通知。 - `scoreline`:分数线字段、院校、专业、记录、趋势、年份。 - `video`:题目视频讲解、批量预加载、通用视频搜索。 - `commerce`:订单创建/列表/详情/状态轮询、支付确认、支付 provider/webhook、激活码预检查/兑换、优惠券领取/抵扣、规则复核、权益查询。 - `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列、分佣设置、佣金来源汇总、结算单和审核/打款状态。 - `platform-admin`:平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。 - - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 + - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码批次、优惠券规则和核销报表、勋章管理/发放、成员管理、角色模板、班级/学生/教师范围权限、权限矩阵、审计查询。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册/分数线/视频 JSON 导入维护。 - `tenant`:域名/租户解析。 - 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。 - 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。 - 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理已落库:`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 天周期,以及租户/地区/班级范围。 - `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `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/*` 已支持租户资金对账导入、预览、批次/明细/异常查询、差错工单状态流、事件轨迹和审计。 - 租户支付账户、短信、OAuth 登录配置接口只保存公开配置;密钥进入 `app_private.tenant_secrets` 或生产 KMS/Vault,API 只返回 `secretRef` 和掩码状态。 - `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` 共同确定可见范围;非全局权限教师只能查看自己负责班级的学生。 - 学生批量导入、批量分班、学生状态、备注和跟进任务都使用独立权限点;教师默认可为范围内学生写备注和跟进任务,但不能批量导入、禁用学生或放大可见班级。 - 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 9e9dc7bf..eacf01eb 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -47,6 +47,7 @@ - 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve` 的 `branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`。 - 租户后台可以接入角色模板和成员 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 /api/profile/notifications`、`POST /api/profile/notifications/status`;租户后台可用 `GET /api/tenant-admin/user-notifications` 做用户通知查看。通知只做展示、跳转和已读/归档状态,业务权限和权益仍以后端源接口为准。 ## 不能误认为已商用完成的部分 @@ -55,7 +56,7 @@ - 短信、微信小程序/网页登录、QQ 登录、微信支付、支付宝支付 provider 已有本地 adapter 和测试覆盖;生产账号、回调域名、证书和商户资料仍需正式联调。 - 对象存储已完成签名 provider、上传后校验、PDF/图片预览、动态水印上下文、资源复检 worker、内置安全扫描、外部 HTTP scanner 接入层和租户后台媒体运营报表;Taro 学生资料页已接短期签名、水印 traceId 展示和强制水印容器第一版;CDN 防盗链、转码级视频水印和真实 AV/内容安全服务联调还要补。 - 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入已可联调;大批量导入可传 `executionMode=async` 交给 imports worker;模板下载、字段映射 API、导入任务详情和导入后复检已可用。租户内容页已经可以选择文件或粘贴内容、下载模板、执行后端预览、编辑本次字段别名、同步/异步提交导入、轮询异步 job、查看问题行并触发/查看复检;后续还要补真实数据 dry-run 验收和更完整的目标入口/集合选择。 -- 数据看板、分佣结算、财务运营、勋章手动发放、签到/积分/反馈/积分活动自动发放、积分任务/兑换第一阶段和主题模板发布基础 API 已可联调;练习/单词/模考触发勋章、积分风控报表、连续签到奖励配置、真实打款 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/scoreline/index.tsx` | `scoreline/records` | | 资料 | `apps/taro/src/pages/student/assets/index.tsx` | `assets`、`assets/preview`、`assets/download`;已接短期签名、过期信息、可见水印覆盖、traceId 展示和强制水印资源外部预览限制 | -| 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me`、`check-in`、`badges`、`exam-countdowns`、`svip-plans`、`orders`、`entitlements`、`activation-codes`、`leaderboard`、`learning/stats`、`learning/trend`、`practice-sessions/history`;`src/services/profile.ts` 已补积分任务/兑换 service,页面 UI 待接 | +| 个人中心 | `apps/taro/src/pages/student/profile/index.tsx` | `profile/me`、`check-in`、`badges`、`exam-countdowns`、`svip-plans`、`orders`、`entitlements`、`activation-codes`、`leaderboard`、`learning/stats`、`learning/trend`、`practice-sessions/history`、`profile/notifications`、`profile/notifications/status`;`src/services/profile.ts` 已补积分任务/兑换/站内通知 service,页面 UI 待接 | -当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/复合题多小题/收藏/反馈/视频 -> 交卷报告逐题复盘 -> 错题/收藏复习”,背单词已经覆盖“单元 -> 今日计划/全单元/收藏练习 -> 卡片翻转 -> 发音 -> 认识/再记上报 -> 本地恢复”,资料页已经覆盖“列表 -> 申请预览/下载短签名 -> 展示水印 traceId -> H5 水印容器预览或确认下载”,个人中心已经覆盖“基础资料 -> 学习报告 -> 14 天趋势 -> 题型表现 -> 最近练习 -> 排名/勋章/签到 -> 会员/订单/激活码”,积分活动/兑换 service 已具备但页面 UI 待接,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。`apps/taro/src/components/RichContent.tsx` 是学生端题干、选项、解析和知识手册的统一安全渲染组件:它只支持受控 Markdown 图片、基础表格、粗体、代码和被 parser 识别出的公式 token;H5 端用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]`,私有题图可用 `asset:`、`content_asset:` 或 `/asset/` 资源引用向后端申请短期预览签名。组件不执行导入内容中的任意 HTML/JS,也会拒绝 `javascript:`、`data:` 等危险图片地址。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补积分任务/兑换页面、小程序公式真机验收、题图资源字段化、小程序分享/支付容器体验。 +当前页面主要用于打通接口和路由。学生端第一版学习闭环已经覆盖“选地区 -> 进题库 -> 创建 session -> 答题卡/答题/主观题自评/复合题多小题/收藏/反馈/视频 -> 交卷报告逐题复盘 -> 错题/收藏复习”,背单词已经覆盖“单元 -> 今日计划/全单元/收藏练习 -> 卡片翻转 -> 发音 -> 认识/再记上报 -> 本地恢复”,资料页已经覆盖“列表 -> 申请预览/下载短签名 -> 展示水印 traceId -> H5 水印容器预览或确认下载”,个人中心已经覆盖“基础资料 -> 学习报告 -> 14 天趋势 -> 题型表现 -> 最近练习 -> 排名/勋章/签到 -> 会员/订单/激活码”,积分活动/兑换/站内通知 service 已具备但页面 UI 待接,会员闭环已经覆盖“选套餐 -> 领优惠券 -> 下单 -> 创建支付参数 -> 状态轮询 -> 订单详情/售后入口”。`apps/taro/src/components/RichContent.tsx` 是学生端题干、选项、解析和知识手册的统一安全渲染组件:它只支持受控 Markdown 图片、基础表格、粗体、代码和被 parser 识别出的公式 token;H5 端用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]`,私有题图可用 `asset:`、`content_asset:` 或 `/asset/` 资源引用向后端申请短期预览签名。组件不执行导入内容中的任意 HTML/JS,也会拒绝 `javascript:`、`data:` 等危险图片地址。后续 UI 需要继续按旧题库视觉和 Taro H5/小程序限制优化,并重点补积分任务/兑换页面、消息中心页面、小程序公式真机验收、题图资源字段化、小程序分享/支付容器体验。 ## 已落地的 Taro 租户后台页面 @@ -95,11 +96,11 @@ | 数据看板 | `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/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/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 平台后台页面 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 5749e022..ee6e98c4 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -33,8 +33,8 @@ | 分数线 | 已建院校、专业、字段、记录表 | 已支持导入映射 | 字段、院校、专业、记录、趋势、年份、租户后台维护 API、JSON 预览导入已实现 | 核心 API 集成测试含导入断言 | 查询、后台维护和批量 JSON 导入基础闭环已实现,复杂动态筛选和 AI 择校上下文待补 | | 题目视频讲解 | 已建 `video_explanations`、`question_videos` | 已支持导入映射 | 单题视频、批量预加载、通用视频搜索、播放签名、视频次数扣减、播放水印上下文、租户后台视频创建绑定 API、JSON 预览导入已实现 | 核心 API 集成测试含播放、水印 traceId 和导入断言 | 播放、权益、后台绑定、动态水印上下文和批量 JSON 导入链路已实现,深度防盗链、转码级水印和播放统计待补 | | 资料下载/PDF | 已扩展 `content_assets`,新增资源台账和导入任务表 | 旧 `app_assets/images` 兼容导入 | 租户后台资源管理、OSS/COS/Supabase Storage 上传/下载签名、上传确认、PDF/图片预览签名、资源访问审计、动态水印上下文、内置安全扫描、外部 HTTP scanner 接入层、题库导出 PDF/Word/每日一练 ZIP 自动发布可信资源、学生端资料列表/下载权限已实现 | 核心 API 集成测试含 SVIP 资料下载、安全扫描门禁、水印 traceId,assets worker 覆盖内置规则与外部 scanner 通过/失败/不可用 fail-closed,exports worker 测试;Taro 类型检查覆盖学生资料水印预览/下载确认 | 资料资源基础闭环可跑,Taro 学生资料页已接短期签名、可见水印覆盖、追踪码展示和强制水印资源外部打开限制第一版;真实 AV/内容安全服务联调、CDN 防盗链、转码/CDN 级水印待补 | -| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges`、积分任务/兑换表 | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、积分任务、积分兑换、题目反馈、考试倒计时、勋章 API 已实现 | API 集成测试、Taro 类型检查 | 学生端个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、激活码和订单入口第一版;Taro service 已补积分任务/兑换;头像上传、账号合并、更细学习建议待补 | -| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品和兑换订单表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约已实现 | 核心 API 集成测试 | 基础运营后台可用;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表和前端活动操作台继续补 | +| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录、`badges/user_badges`、积分任务/兑换表、`user_notifications` | 已支持部分用户资料和勋章导入 | 个人资料、目标院校/专业、手机号绑定/换绑、会员状态、最近练习、统计聚合、签到积分、积分任务、积分兑换、题目反馈、考试倒计时、勋章和站内通知 API 已实现 | API 集成测试、Taro 类型检查 | 学生端个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、激活码和订单入口第一版;Taro service 已补积分任务/兑换/站内通知;头像上传、账号合并、更细学习建议和消息中心 UI 待补 | +| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | 基础运营后台可用;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和前端活动/消息操作台继续补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI | | 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 | @@ -295,7 +295,7 @@ platform-admin: 1. 正式鉴权:API 已支持 Supabase Auth JWT;生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id`、`x-platform-admin-key` 兼容入口。 2. 国内能力接入:短信、微信小程序登录、微信网页登录、QQ 登录、微信支付、支付宝支付、发起退款、退款查询确认和退款通知 webhook 的租户级配置入口与本地 provider 验证已具备;资金对账、支付/退款补偿和差错工单已完成基础闭环,真实生产账号联调和官方账单下载仍需继续完善。 -3. 核心缺口 API:学生端个人中心、手机号绑定/换绑、分数线、题目视频详情、背单词进度/收藏、签到积分、题目反馈和勋章已补基础 API;下一步重点是学习报告可视化、后台统计和真实业务验收。 +3. 核心缺口 API:学生端个人中心、手机号绑定/换绑、分数线、题目视频详情、背单词进度/收藏、签到积分、题目反馈、勋章和站内通知已补基础 API;下一步重点是学习报告可视化、消息中心 UI、后台统计和真实业务验收。 4. 后台能力:题库录入、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步批量导入、导入后复检、模板下载/字段映射 API、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 API;前端操作台待补。 5. 自动化测试:已建立核心 API、租户隔离、权限矩阵、后台维护、资源/导入、微信/支付宝支付 webhook、退款、支付补偿、资金对账、差错工单、优惠券/激活码/订单状态集成测试;仍需真实数据导入回归、官方账单下载和前端端到端测试。 6. Taro 前端:建立 `apps/taro` 或等价跨端应用,把 H5 和小程序统一走同一套 API client。 @@ -309,5 +309,5 @@ platform-admin: 2. 补公共题库生产定时调度/失败告警、租户套餐地区/科目/题库范围限制、主题模板系统。 3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 4. 补视频商用控制:深度防盗链、转码级水印和播放统计。 -5. 补 AI 择校推荐报告、排行榜防刷/预聚合、连续签到奖励配置、积分风控报表和更多学习行为触发勋章。 +5. 补 AI 择校推荐报告、排行榜防刷/预聚合、连续签到奖励配置、积分风控报表、外部订阅消息/短信和更多学习行为触发勋章。 6. 接真实短信/OAuth 生产账号、真实生产账单格式验收和异常订单运营台,并继续推进 Taro scaffold。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 4c0c3546..8e7b7445 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -31,10 +31,10 @@ | 知识手册 | `Handbook*.tsx` | 已覆盖 | Taro 已接手册科目/章节/条目、RichContent 安全渲染、H5 KaTeX 公式渲染和私有资源 ID 短签名地基第一版;后续补搜索、高亮、目录锚点、小程序公式真机验收和图片资源字段化 | | 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势、后台维护和 JSON 批量导入已有;后续补复杂筛选优化和 AI 择校数据上下文 | | 商城/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 级水印和生命周期策略 | | AI 择校推荐 | 业务规划新增 | 部分覆盖 | 已有 SVIP 门禁、学生输入 schema、地区/分数线上下文、稳定 JSON 输出、报告台账、审计和 Taro 学生端基础页;真实 AI provider、prompt 版本管理后台、报告 PDF 渲染和更细推荐算法待补 | -| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 | +| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分和站内通知已覆盖;缺前端消息中心、外部订阅消息/短信提醒、批量统计和内容修复闭环 | | 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等、签到/积分阈值自动发放勋章、积分活动任务、积分兑换商品、优惠券兑换履约和租户后台配置已覆盖;后续补连续签到奖励配置、积分风控报表和更多系统触发任务 | | 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 部分覆盖 | 已有刷题数、积分、背单词、模考最高分排行榜,支持租户/地区/班级范围和当前用户排名;后续补防刷、日/周榜预聚合、运营后台排名看板 | @@ -57,7 +57,7 @@ | SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI | | 优惠券 | 已覆盖 | 后台配置、前台领取、同用户同券未核销幂等、下单抵扣、全额优惠自动开通权益、最低金额、封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细和报表已有;前端营销活动 UI 继续完善 | | 激活码 | 已覆盖 | 批次、生成、预检查、兑换、自用码拒绝、地区校验主链路已有 | -| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示、权限隔离、签到连续天数、积分阈值、反馈解决和积分活动自动发放已覆盖;练习次数、单词掌握、模考成绩、发放通知和前端运营 UI 待补 | +| 勋章管理 | 部分覆盖 | 后台勋章维护、手动发放、重复发放幂等、学生端勋章展示、权限隔离、签到连续天数、积分阈值、反馈解决、积分活动自动发放和站内通知已覆盖;练习次数、单词掌握、模考成绩触发和前端运营 UI 待补 | | 题库录入 | 已覆盖 | 单题创建/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入、集合/蓝图、导入后复检、模板下载、字段映射 API 和导入任务详情已有;Taro 租户内容页已接上传/粘贴预览、模板下载、字段别名编辑、同步/异步执行、异步轮询和复检结果详情第一版;真实数据验收待补 | | 题库导出 PDF/Word/JSON/每日一练 ZIP | 部分覆盖 | 服务端 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练 ZIP 图片素材包异步导出已补,含租户内容编辑权限、跨租户拒绝、答案/解析开关、子题脱敏、导出 job、审计、水印、`content_assets` 发布/下载路径和 exports worker;每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图包已补;后续补更精细试卷模板和后台操作台体验 | | 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 | @@ -65,6 +65,7 @@ | 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 | | 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 和 JSON 批量导入已有 | | 视频维护/绑定 | 已覆盖 | video CRUD、question-video 绑定和视频 JSON 批量导入已有 | +| 用户站内通知 | 部分覆盖 | 反馈处理、反馈奖励、勋章发放和积分兑换会写入 `user_notifications`,学生可查/标记状态,租户后台可按权限查看租户内通知 | 前端消息中心、租户后台通知页、外部微信订阅消息/短信和批量统计待补 | | CRM 配置和队列 | 部分覆盖 | 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、钉钉/飞书/企微 worker、签名和重试已有;富卡片模板、失败告警、死信运营台和批量推送待补 | | 对象存储配置 | 部分覆盖 | 系统 env provider、上传签名、上传确认、预览下载签名、资源访问审计、水印 traceId、内置安全扫描和外部 HTTP scanner 接入层已有;租户级存储策略、CDN/转码级水印/真实 AV 服务联调待补 | @@ -103,9 +104,9 @@ 6. 公共题库商业化:平台公共/地区题库授权、租户快照采纳、手动同步、自动同步 worker、同步通知、冲突查询、租户自改冲突保护和单条/批量冲突处理已完成基础闭环;还需生产定时调度、失败告警和更完整运营后台消息。 7. CRM/销售结算:CRM worker、跟进分配策略、分佣规则、结算单、审核、打款状态、导出和凭证复核基础闭环已完成;仍缺富卡片模板、失败告警、死信运营台、真实打款 provider、发票和销售结算看板。 8. AI 择校推荐增强:后端 `local_rules` 地基、SVIP 门禁、报告列表/详情和 Taro 基础页已完成;仍缺真实 AI provider、prompt 编排、PDF 报告和后台运营配置。 -9. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 +9. 题目反馈增强:站内通知后端第一版已完成;仍缺前端消息中心、外部订阅消息/短信提醒、问题聚合统计和内容修复闭环。 10. 积分活动增强:积分兑换、活动任务、优惠券兑换履约和后台配置第一阶段已完成;仍缺连续签到奖励配置、积分风控报表、活动效果看板和更细系统任务触发。 -11. 勋章增强:后台维护、手动发放、签到/积分/反馈/活动任务自动发放已有;仍缺按练习次数、单词掌握、模考成绩自动发放,以及发放通知。 +11. 勋章增强:后台维护、手动发放、签到/积分/反馈/活动任务自动发放和发放站内通知已有;仍缺按练习次数、单词掌握、模考成绩自动发放,以及前端运营配置体验。 ### P0:前端联调到云端前 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index a6471d18..35b4b7e9 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -8,8 +8,8 @@ - Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。 - Node.js API 分层:`core/features`。 -- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章。 -- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 +- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。 +- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。 - 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。 - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。 @@ -22,8 +22,9 @@ - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;Taro 租户学生运营页已接学生创建/更新、状态切换、批量导入、批量分班、备注和跟进任务第一版;后续补批量 CRM 推送、自动学习督导和更细导入模板体验。 - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。 -- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决和积分活动任务自动发放第一阶段已完成并纳入 API 集成测试;后续补练习次数、单词掌握、模考成绩和发放通知。 -- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;后续补连续签到奖励配置、积分风控报表和前端操作台。 +- 勋章管理已完成租户后台维护、手动发放、重复发放幂等、学生个人中心展示、权限点和集成测试;签到连续天数、积分阈值、反馈解决和积分活动任务自动发放第一阶段已完成并纳入 API 集成测试;自动/手动发放会写入用户站内通知。后续补练习次数、单词掌握、模考成绩触发和前端运营配置体验。 +- 积分活动和兑换第一阶段已完成:租户后台可配置积分任务/兑换商品并查看领取/兑换记录,学生端可查询任务、领取奖励、查看兑换商品、兑换并生成 `redeem_cost` 积分流水;优惠券兑换会生成 `coupon_redemptions`,手工/自定义商品进入待履约订单;兑换完成或待履约会写入用户站内通知。后续补连续签到奖励配置、积分风控报表和前端操作台。 +- 用户站内通知第一版已完成:反馈处理、反馈奖励、勋章发放、积分兑换会创建 `user_notifications`;学生端可查询/标记状态,租户后台具备 `notifications:read` 权限的成员可查看租户内通知。后续补 Taro 消息中心 UI、租户后台通知页、外部微信订阅消息/短信和批量统计。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 - 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。 - 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。 @@ -129,7 +130,7 @@ 9. 积分和反馈增强 - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 - 已完成勋章后台维护、手动发放、学生端展示,以及签到连续天数、积分阈值、反馈解决自动发放第一阶段。 - - 积分兑换、活动任务和优惠券兑换履约第一阶段已完成;继续补连续签到奖励配置、练习/单词/模考触发勋章、积分风控报表、处理通知和反馈聚合统计。 + - 积分兑换、活动任务、优惠券兑换履约和站内通知后端第一阶段已完成;继续补连续签到奖励配置、练习/单词/模考触发勋章、积分风控报表、前端消息中心、外部订阅消息/短信和反馈聚合统计。 10. 数据看板 - 已完成首版实时聚合接口,覆盖收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量、套餐销量、运营动态、24h 活跃度和激活码使用情况。 @@ -184,7 +185,7 @@ - H5 和小程序共用同一套业务 API client。 - 租户通过域名、小程序配置或启动参数解析。 - 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/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. 个人中心 - 会员权益、订单、激活码兑换 - - 错题本、收藏夹、学习统计、排行榜、勋章 + - 错题本、收藏夹、学习统计、排行榜、勋章、站内通知 - Taro 已接学习报告、14 天趋势、题型表现、最近练习和 7 日答题榜当前排名第一版 - 会员收银台、优惠券领取、下单、支付参数、订单详情、状态轮询和售后入口已接第一版 @@ -236,7 +237,7 @@ ## 推荐下一步顺序 1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。 -2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版,积分任务/兑换 service 已补;继续补小程序公式真机验收、题图资源字段化、个人中心积分任务/兑换 UI、背单词更细统计、小程序支付容器、分享场景和状态管理。 +2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版,积分任务/兑换/站内通知 service 已补;继续补小程序公式真机验收、题图资源字段化、个人中心积分任务/兑换 UI、消息中心 UI、背单词更细统计、小程序支付容器、分享场景和状态管理。 3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。 4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 3db92fc4..a2b20c0d 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -266,7 +266,7 @@ tenant::theme | 商城/收银台 | `/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` | | 激活码 | `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` | | 租户数据看板 | `GET /api/tenant-admin/dashboard?timeRange=30d®ionId=...` | | 租户主题模板 | `GET /api/tenant-admin/theme-templates`、`GET /api/tenant-admin/theme`、`POST /api/tenant-admin/theme/preview`、`POST /api/tenant-admin/theme/publish` | @@ -1205,7 +1205,70 @@ POST /api/profile/exchange-items/redeem - `questionId` 如存在,后端会校验题目必须属于当前租户。 - 反馈状态由租户后台处理,学生可用 `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": [""], + "status": "read | dismissed | archived" +} +``` + +返回项包含: + +```json +{ + "items": [ + { + "id": "", + "notificationType": "badge_granted", + "status": "unread", + "severity": "success", + "title": "获得新勋章", + "message": "你获得了「连续签到」勋章。", + "actionLabel": "查看勋章", + "actionPath": "/student/profile?tab=badges", + "sourceType": "badges", + "sourceId": "", + "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=¬ificationType=point_exchange_pending_fulfillment +``` + +- 租户后台需要 `notifications:read` 权限。 +- 后台只能查看租户内用户通知,不代学生改已读状态。 +- 前端可以按 `actionPath` 做站内跳转,但跳转后的页面仍要重新请求对应业务接口,不能信任通知 metadata 作为最终业务数据。 +- 当前支持的通知类型包括 `feedback_status_updated`、`feedback_reward_granted`、`badge_granted`、`point_exchange_completed`、`point_exchange_pending_fulfillment`。 ## 题库新模型接入方式 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 1e396add..728e3798 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -1774,6 +1774,38 @@ async function testProfile() { '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', { method: 'POST', body: { @@ -6572,6 +6604,16 @@ async function testTenantAdminOps() { assert.equal(grant.item?.badgeId, ids.tenantBadge, 'tenant admin should grant badge'); 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', { userId: TENANT_ADMIN_USER_ID, method: 'POST', @@ -6684,6 +6726,38 @@ async function testTenantAdminOps() { '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 rewardCountAfterResolve = rewardEventsAfterResolve.items?.filter( 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.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', { method: 'POST', 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: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 === '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 === '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'); diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index 3a516aeb..3286a2b2 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -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( ` delete from public.report_status_events @@ -339,12 +357,13 @@ async function main() { tenantId, ids.user, 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}`, `auto:${ids.tenantCheckInBadge}:user:${ids.user}`, `auto:${ids.tenantScoreBadge}: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-%' ) `, - [tenantId, [ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge]], + [tenantId, [ids.tenantBadge, ids.tenantCheckInBadge, ids.tenantScoreBadge, ids.tenantFeedbackBadge, ids.tenantActivityBadge]], ); await client.query( diff --git a/supabase/migrations/202606290034_user_notifications.sql b/supabase/migrations/202606290034_user_notifications.sql new file mode 100644 index 00000000..b9271ec9 --- /dev/null +++ b/supabase/migrations/202606290034_user_notifications.sql @@ -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();