From 8f92d0427ebc3f6c6a0ac6ef79b4696e03676ef6 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 01:48:38 +0800 Subject: [PATCH] feat: add feedback checkins exam dates --- README.md | 8 +- apps/api/src/features/catalog/index.ts | 2 + apps/api/src/features/catalog/routes.ts | 60 +++ apps/api/src/features/profile/index.ts | 15 +- apps/api/src/features/profile/routes.ts | 318 ++++++++++++++- apps/api/src/features/tenant-admin/auth.ts | 2 + apps/api/src/features/tenant-admin/index.ts | 12 + .../src/features/tenant-admin/operations.ts | 369 ++++++++++++++++++ docs/refactor/backend-capability-status.md | 5 + docs/refactor/backend-progress.md | 18 +- docs/refactor/legacy-feature-gap-matrix.md | 32 +- docs/refactor/next-development-todo.md | 20 +- docs/refactor/taro-frontend-integration.md | 59 ++- scripts/api-integration-test.js | 162 ++++++++ scripts/smoke-seed.js | 88 +++++ ...606290004_feedback_checkins_exam_dates.sql | 95 +++++ 16 files changed, 1232 insertions(+), 33 deletions(-) create mode 100644 apps/api/src/features/tenant-admin/operations.ts create mode 100644 supabase/migrations/202606290004_feedback_checkins_exam_dates.sql diff --git a/README.md b/README.md index 3763d9d0..ed53679a 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 批量导入。 -- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。 +- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、考试倒计时、签到积分、题目反馈、分数线、题目视频、订单、权益、激活码兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。 - PocketBase schema/数据导入器雏形和导入后校验脚本。 @@ -23,7 +23,7 @@ 还没有达到生产交付的部分: - Supabase Auth/JWT、租户角色模板、班级/教师/学生范围权限已可联调;生产前还要做真实云端 Auth/JWKS 回归和 RLS 深测。 -- 真实短信、微信登录、QQ 登录、微信支付、支付宝等 provider adapter 还没接完。 +- 阿里云/腾讯云短信、微信小程序登录、微信支付、支付宝主链路已完成本地适配;微信网页登录、QQ 登录、手机号换绑、退款/对账和真实生产账号联调还没接完。 - OSS/COS/Supabase Storage 上传下载签名 provider 已接入;上传后校验、PDF 预览、防盗链和视频水印还没完成。 - Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。 - Taro 跨端前端还没开始 scaffold。 @@ -178,4 +178,4 @@ npm run check:refactor 2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。 3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。 4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。 -5. 微信网页/QQ 登录、退款对账、CRM worker、公共题库授权、租户采纳、题目反馈、签到积分和排行榜。 +5. 微信网页/QQ 登录、退款对账、CRM worker、公共题库授权、租户采纳、订单状态轮询、激活码预检查、积分活动深化和排行榜。 diff --git a/apps/api/src/features/catalog/index.ts b/apps/api/src/features/catalog/index.ts index 573514ab..43815a24 100644 --- a/apps/api/src/features/catalog/index.ts +++ b/apps/api/src/features/catalog/index.ts @@ -11,6 +11,7 @@ import { announcementsRoute, bannersRoute, categoriesRoute, + examDatesRoute, faqsRoute, handbookChaptersRoute, handbookEntriesRoute, @@ -55,5 +56,6 @@ export const catalogRoutes: RouteDefinition[] = [ ['GET', '/api/catalog/announcements', announcementsRoute], ['GET', '/api/catalog/products', productsRoute], ['GET', '/api/catalog/timelines', timelinesRoute], + ['GET', '/api/catalog/exam-dates', examDatesRoute], ['GET', '/api/catalog/svip-plans', svipPlansRoute], ]; diff --git a/apps/api/src/features/catalog/routes.ts b/apps/api/src/features/catalog/routes.ts index c8971ca0..7070c2e7 100644 --- a/apps/api/src/features/catalog/routes.ts +++ b/apps/api/src/features/catalog/routes.ts @@ -2,6 +2,22 @@ import type { RequestContext } from '../../core/http.js'; import { query } from '../../core/db.js'; import { intParam, tenantIdFrom } from '../../core/request.js'; +function dateOnly(value: unknown) { + if (!value) return null; + if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10); + if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 10); + return null; +} + +function daysUntil(dateValue: unknown, today: Date) { + const dateText = dateOnly(dateValue); + if (!dateText) return null; + const target = new Date(`${dateText}T00:00:00.000Z`); + if (Number.isNaN(target.getTime())) return null; + const current = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); + return Math.ceil((target.getTime() - current.getTime()) / 86_400_000); +} + export async function regionsRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); @@ -566,6 +582,50 @@ export async function timelinesRoute(ctx: RequestContext) { return { items }; } +export async function examDatesRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const regionId = ctx.url.searchParams.get('regionId'); + const schoolId = ctx.url.searchParams.get('schoolId'); + const limit = intParam(ctx, 'limit', 50, 200); + const params: unknown[] = [tenantId]; + const filters = ['tenant_id = $1', 'is_active = true']; + if (regionId) { + params.push(regionId); + filters.push(`(region_id = $${params.length}::uuid or region_id is null)`); + } + if (schoolId) { + params.push(schoolId); + filters.push(`(school_id = $${params.length}::uuid or school_id is null)`); + } + params.push(limit); + + const today = new Date(); + const items = await query<{ + examDate: string | null; + } & Record>( + ` + select id, legacy_id as "legacyId", region_id as "regionId", school_id as "schoolId", + exam_name as "examName", exam_date::text as "examDate", exam_type as "examType", + description, metadata, sort_order as "order", is_active as "isActive", + created_at as "createdAt", updated_at as "updatedAt" + from public.exam_dates + where ${filters.join(' and ')} + order by exam_date asc nulls last, sort_order asc + limit $${params.length} + `, + params, + ); + + return { + items: items.map(item => { + return { + ...item, + daysLeft: daysUntil(item.examDate, today), + }; + }), + }; +} + export async function svipPlansRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const regionId = ctx.url.searchParams.get('regionId'); diff --git a/apps/api/src/features/profile/index.ts b/apps/api/src/features/profile/index.ts index 57ce5445..e3236d6a 100644 --- a/apps/api/src/features/profile/index.ts +++ b/apps/api/src/features/profile/index.ts @@ -1,7 +1,20 @@ import type { RouteDefinition } from '../../core/router.js'; -import { profileMeRoute, updateProfileMeRoute } from './routes.js'; +import { + checkInRoute, + examCountdownRoute, + feedbacksRoute, + profileMeRoute, + scoreEventsRoute, + submitFeedbackRoute, + updateProfileMeRoute, +} from './routes.js'; export const profileRoutes: RouteDefinition[] = [ ['GET', '/api/profile/me', profileMeRoute], ['PATCH', '/api/profile/me', updateProfileMeRoute], + ['POST', '/api/profile/check-in', checkInRoute], + ['GET', '/api/profile/score-events', scoreEventsRoute], + ['GET', '/api/profile/feedbacks', feedbacksRoute], + ['POST', '/api/profile/feedbacks', submitFeedbackRoute], + ['GET', '/api/profile/exam-countdowns', examCountdownRoute], ]; diff --git a/apps/api/src/features/profile/routes.ts b/apps/api/src/features/profile/routes.ts index b15fbd83..e311a6cf 100644 --- a/apps/api/src/features/profile/routes.ts +++ b/apps/api/src/features/profile/routes.ts @@ -1,6 +1,6 @@ import { HttpError, type RequestContext } from '../../core/http.js'; -import { intParam, optionalString, readJsonBody, tenantIdFrom, userIdFrom } from '../../core/request.js'; -import { query, queryOne } from '../../core/db.js'; +import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js'; +import { query, queryOne, transaction } from '../../core/db.js'; type JsonMap = Record; @@ -39,6 +39,40 @@ function jsonArrayBodyValue(value: unknown) { return JSON.stringify(Array.isArray(value) ? value : []); } +function nullableString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function optionalChoice(value: unknown, allowed: string[], fallback: string) { + const candidate = nullableString(value) || fallback; + if (!allowed.includes(candidate)) { + throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE'); + } + return candidate; +} + +function toDateOnly(value: unknown) { + if (!value) return null; + if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10); + if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 10); + return null; +} + +function daysBetween(dateValue: unknown, today: Date) { + const dateText = toDateOnly(dateValue); + if (!dateText) return null; + const target = new Date(`${dateText}T00:00:00.000Z`); + if (Number.isNaN(target.getTime())) return null; + const current = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); + return Math.ceil((target.getTime() - current.getTime()) / 86_400_000); +} + +const FEEDBACK_TYPES = ['question_error', 'content_error', 'video_error', 'asset_error', 'system_bug', 'suggestion', 'other']; + export async function profileMeRoute(ctx: RequestContext) { const tenantId = await tenantIdFrom(ctx); const userId = await userIdFrom(ctx); @@ -257,3 +291,283 @@ export async function updateProfileMeRoute(ctx: RequestContext) { return { item }; } + +export async function checkInRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + + const item = await transaction(async client => { + const today = new Date().toISOString().slice(0, 10); + const existing = await client.query<{ + lastCheckInDate: string | null; + score: number; + stats: Record; + }>( + ` + select sp.last_check_in_date as "lastCheckInDate", u.score, sp.stats + from public.student_profiles sp + join public.platform_users u on u.id = sp.user_id + where sp.tenant_id = $1 and sp.user_id = $2 + limit 1 + for update of sp, u + `, + [tenantId, userId], + ); + const profile = existing.rows[0]; + if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND'); + if (profile.lastCheckInDate === today) { + return { + checkedIn: false, + alreadyCheckedIn: true, + pointsAdded: 0, + score: Number(profile.score || 0), + lastCheckInDate: today, + }; + } + + const yesterday = new Date(); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + const yesterdayText = yesterday.toISOString().slice(0, 10); + const stats = objectValue(profile.stats); + const previousStreak = Number(stats.checkInStreak || 0); + const streak = profile.lastCheckInDate === yesterdayText ? previousStreak + 1 : 1; + const pointsAdded = 10 + Math.min(Math.max(streak - 1, 0), 6); + const balanceAfter = Number(profile.score || 0) + pointsAdded; + + const nextStats = { + ...stats, + checkInStreak: streak, + lastCheckInPoints: pointsAdded, + }; + + const ledger = await client.query( + ` + insert into public.user_score_events ( + tenant_id, user_id, event_type, points, balance_after, + source_type, idempotency_key, metadata + ) + values ($1, $2, 'check_in', $3, $4, 'student_profiles', $5, $6::jsonb) + on conflict (tenant_id, idempotency_key) do nothing + returning id, event_type as "eventType", points, balance_after as "balanceAfter", + source_type as "sourceType", created_at as "createdAt" + `, + [ + tenantId, + userId, + pointsAdded, + balanceAfter, + `check_in:${userId}:${today}`, + JSON.stringify({ checkInDate: today, streak }), + ], + ); + + if (!ledger.rows[0]) { + return { + checkedIn: false, + alreadyCheckedIn: true, + pointsAdded: 0, + score: Number(profile.score || 0), + lastCheckInDate: today, + }; + } + + const updatedUser = await client.query<{ score: number }>( + ` + update public.platform_users + set score = score + $2, + updated_at = now() + where id = $1 + returning score + `, + [userId, pointsAdded], + ); + + await client.query( + ` + update public.student_profiles + set last_check_in_date = $3::date, + stats = $4::jsonb, + updated_at = now() + where tenant_id = $1 and user_id = $2 + `, + [tenantId, userId, today, JSON.stringify(nextStats)], + ); + + return { + checkedIn: true, + alreadyCheckedIn: false, + pointsAdded, + streak, + score: updatedUser.rows[0]?.score || balanceAfter, + lastCheckInDate: today, + ledger: { + ...ledger.rows[0], + balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter, + }, + }; + }); + + return { item }; +} + +export async function scoreEventsRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const limit = intParam(ctx, 'limit', 50, 200); + + const items = await query( + ` + select id, event_type as "eventType", points, balance_after as "balanceAfter", + source_type as "sourceType", source_id as "sourceId", + metadata, created_at as "createdAt" + from public.user_score_events + where tenant_id = $1 and user_id = $2 + order by created_at desc + limit $3 + `, + [tenantId, userId, limit], + ); + return { items }; +} + +export async function feedbacksRoute(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 params: unknown[] = [tenantId, userId]; + const filters = ['r.tenant_id = $1', 'r.user_id = $2']; + if (status) { + params.push(status); + filters.push(`r.status = $${params.length}`); + } + params.push(limit); + + const items = await query( + ` + select r.id, r.question_id as "questionId", q.type as "questionType", + r.type, r.category, r.title, r.description, r.status, r.priority, + r.resolution, r.handled_by as "handledBy", r.handled_at as "handledAt", + r.attachments, r.metadata, r.created_at as "createdAt", r.updated_at as "updatedAt" + from public.reports r + left join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id + where ${filters.join(' and ')} + order by r.created_at desc + limit $${params.length} + `, + params, + ); + return { items }; +} + +export async function submitFeedbackRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const body = await readJsonBody(ctx); + const userId = await userIdFrom(ctx, body); + const questionId = nullableString(body.questionId); + const type = optionalChoice(body.type, FEEDBACK_TYPES, 'question_error'); + const description = requiredString(body, 'description'); + const attachments = Array.isArray(body.attachments) ? body.attachments : []; + + const item = await transaction(async client => { + if (questionId) { + const question = await client.query<{ id: string }>( + 'select id from public.questions where tenant_id = $1 and id = $2 and status <> \'archived\' limit 1', + [tenantId, questionId], + ); + if (!question.rows[0]) throw new HttpError(404, 'Question not found for this tenant', 'QUESTION_NOT_FOUND'); + } + + const result = await client.query( + ` + insert into public.reports ( + tenant_id, question_id, user_id, type, category, title, description, + status, priority, contact, attachments, metadata + ) + values ($1, $2::uuid, $3, $4, $5, $6, $7, 'pending', $8, $9, $10::jsonb, $11::jsonb) + returning id, question_id as "questionId", user_id as "userId", type, category, + title, description, status, priority, contact, attachments, metadata, + created_at as "createdAt", updated_at as "updatedAt" + `, + [ + tenantId, + questionId, + userId, + type, + nullableString(body.category), + nullableString(body.title), + description, + optionalChoice(body.priority, ['low', 'normal', 'high', 'urgent'], 'normal'), + nullableString(body.contact), + JSON.stringify(attachments), + JSON.stringify(objectValue(body.metadata)), + ], + ); + + await client.query( + ` + insert into public.report_status_events (tenant_id, report_id, from_status, to_status, note, actor_user_id) + values ($1, $2, null, 'pending', 'student submitted feedback', $3) + `, + [tenantId, result.rows[0].id, userId], + ); + + return result.rows[0]; + }); + + return { item }; +} + +export async function examCountdownRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const limit = intParam(ctx, 'limit', 5, 20); + + const profile = await queryOne<{ + regionId: string | null; + selectedSchoolId: string | null; + }>( + ` + select region_id as "regionId", selected_school_id as "selectedSchoolId" + from public.student_profiles + where tenant_id = $1 and user_id = $2 + limit 1 + `, + [tenantId, userId], + ); + if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND'); + + const items = await query<{ + id: string; + examName: string; + examDate: string | null; + } & Record>( + ` + select id, region_id as "regionId", school_id as "schoolId", + exam_name as "examName", exam_date::text as "examDate", + exam_type as "examType", description, metadata, + sort_order as "order", is_active as "isActive", + created_at as "createdAt", updated_at as "updatedAt" + from public.exam_dates + where tenant_id = $1 + and is_active = true + and ($2::uuid is null or region_id is null or region_id = $2::uuid) + and ($3::uuid is null or school_id is null or school_id = $3::uuid) + order by exam_date asc nulls last, sort_order asc + limit $4 + `, + [tenantId, profile.regionId, profile.selectedSchoolId, limit], + ); + + const today = new Date(); + return { + items: items.map(item => ({ + ...item, + daysLeft: daysBetween(item.examDate, today), + })), + target: { + regionId: profile.regionId, + schoolId: profile.selectedSchoolId, + }, + }; +} diff --git a/apps/api/src/features/tenant-admin/auth.ts b/apps/api/src/features/tenant-admin/auth.ts index 1ef19ca3..6dbb8d37 100644 --- a/apps/api/src/features/tenant-admin/auth.ts +++ b/apps/api/src/features/tenant-admin/auth.ts @@ -102,6 +102,8 @@ export function tenantPermissionCatalog() { { key: 'referral:write', label: '客资归属管理' }, { key: 'crm:read', label: 'CRM 队列查看' }, { key: 'crm:write', label: 'CRM 入队和重试' }, + { key: 'feedback:read', label: '反馈查看' }, + { key: 'feedback:write', label: '反馈处理' }, { key: 'classes:read', label: '班级查看' }, { key: 'classes:write', label: '班级管理' }, { key: 'students:read', label: '学生查看' }, diff --git a/apps/api/src/features/tenant-admin/index.ts b/apps/api/src/features/tenant-admin/index.ts index 3983458c..572723ab 100644 --- a/apps/api/src/features/tenant-admin/index.ts +++ b/apps/api/src/features/tenant-admin/index.ts @@ -17,6 +17,13 @@ import { upsertTenantClassRoute, upsertTenantStudentRoute, } from './classes.js'; +import { + tenantExamDatesRoute, + tenantFeedbackEventsRoute, + tenantFeedbacksRoute, + updateTenantFeedbackStatusRoute, + upsertTenantExamDateRoute, +} from './operations.js'; import { activationCodesRoute, announcementsAdminRoute, @@ -90,6 +97,11 @@ export const tenantAdminRoutes: RouteDefinition[] = [ ['PUT', '/api/tenant-admin/faqs', upsertFaqRoute], ['GET', '/api/tenant-admin/announcements', announcementsAdminRoute], ['PUT', '/api/tenant-admin/announcements', upsertAnnouncementRoute], + ['GET', '/api/tenant-admin/exam-dates', tenantExamDatesRoute], + ['PUT', '/api/tenant-admin/exam-dates', upsertTenantExamDateRoute], + ['GET', '/api/tenant-admin/feedbacks', tenantFeedbacksRoute], + ['POST', '/api/tenant-admin/feedbacks/status', updateTenantFeedbackStatusRoute], + ['GET', '/api/tenant-admin/feedbacks/events', tenantFeedbackEventsRoute], ['GET', '/api/tenant-admin/code-batches', codeBatchesRoute], ['PUT', '/api/tenant-admin/code-batches', upsertCodeBatchRoute], ['GET', '/api/tenant-admin/activation-codes', activationCodesRoute], diff --git a/apps/api/src/features/tenant-admin/operations.ts b/apps/api/src/features/tenant-admin/operations.ts new file mode 100644 index 00000000..0b1af7ff --- /dev/null +++ b/apps/api/src/features/tenant-admin/operations.ts @@ -0,0 +1,369 @@ +import type pg from 'pg'; +import { HttpError, type RequestContext } from '../../core/http.js'; +import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js'; +import { query, transaction } from '../../core/db.js'; +import { + requireTenantAdmin, + requireTenantPermission, + type TenantAdminAuth, +} from './auth.js'; + +type JsonBody = Record; + +const REPORT_STATUSES = ['pending', 'accepted', 'rejected', 'resolved', 'closed']; +const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent']; + +function nullableString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function intValue(value: unknown, fallback: number) { + const numberValue = Number(value ?? fallback); + return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback; +} + +function boolValue(value: unknown, fallback: boolean) { + return typeof value === 'boolean' ? value : fallback; +} + +function optionalChoice(value: unknown, allowed: string[], fallback: string) { + const candidate = nullableString(value) || fallback; + if (!allowed.includes(candidate)) { + throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE'); + } + return candidate; +} + +async function recordAudit( + client: pg.PoolClient, + auth: TenantAdminAuth, + action: string, + targetType: string, + targetId: string | null, + details: Record = {}, +) { + await client.query( + ` + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, $2, $3, $4, $5, $6::jsonb) + `, + [auth.tenantId, auth.userId, action, targetType, targetId, JSON.stringify(details)], + ); +} + +async function ensureTenantReference( + client: pg.PoolClient, + tableName: 'regions' | 'schools', + tenantId: string, + id: string | null, + errorCode: string, +) { + if (!id) return; + const result = await client.query<{ id: string }>( + `select id from public.${tableName} where tenant_id = $1 and id = $2 limit 1`, + [tenantId, id], + ); + if (!result.rows[0]) throw new HttpError(400, `${tableName} id is not in this tenant`, errorCode); +} + +export async function tenantExamDatesRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'marketing:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const regionId = stringParam(ctx, 'regionId'); + const schoolId = stringParam(ctx, 'schoolId'); + const isActive = ctx.url.searchParams.get('isActive'); + const params: unknown[] = [auth.tenantId]; + const filters = ['tenant_id = $1']; + if (regionId) { + params.push(regionId); + filters.push(`region_id = $${params.length}::uuid`); + } + if (schoolId) { + params.push(schoolId); + filters.push(`school_id = $${params.length}::uuid`); + } + if (isActive === 'true' || isActive === 'false') { + params.push(isActive === 'true'); + filters.push(`is_active = $${params.length}`); + } + params.push(limit); + + const items = await query( + ` + select id, legacy_id as "legacyId", region_id as "regionId", school_id as "schoolId", + exam_name as "examName", exam_date as "examDate", exam_type as "examType", + description, metadata, sort_order as "sortOrder", is_active as "isActive", + created_at as "createdAt", updated_at as "updatedAt" + from public.exam_dates + where ${filters.join(' and ')} + order by exam_date asc nulls last, sort_order asc + limit $${params.length} + `, + params, + ); + + return { items }; +} + +export async function upsertTenantExamDateRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'marketing:write'); + const body = await readJsonBody(ctx); + + const item = await transaction(async client => { + const regionId = nullableString(body.regionId); + const schoolId = nullableString(body.schoolId); + await ensureTenantReference(client, 'regions', auth.tenantId, regionId, 'REGION_NOT_FOUND'); + await ensureTenantReference(client, 'schools', auth.tenantId, schoolId, 'SCHOOL_NOT_FOUND'); + + const result = await client.query( + ` + insert into public.exam_dates ( + id, tenant_id, region_id, school_id, legacy_id, exam_name, exam_date, + exam_type, description, metadata, sort_order, is_active + ) + values ( + coalesce($2::uuid, gen_random_uuid()), $1, $3::uuid, $4::uuid, $5, $6, $7::date, + $8, $9, $10::jsonb, $11, $12 + ) + on conflict (id) + do update set region_id = excluded.region_id, + school_id = excluded.school_id, + legacy_id = coalesce(excluded.legacy_id, public.exam_dates.legacy_id), + exam_name = excluded.exam_name, + exam_date = excluded.exam_date, + exam_type = excluded.exam_type, + description = excluded.description, + metadata = excluded.metadata, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = now() + where public.exam_dates.tenant_id = excluded.tenant_id + returning id, legacy_id as "legacyId", region_id as "regionId", school_id as "schoolId", + exam_name as "examName", exam_date as "examDate", exam_type as "examType", + description, metadata, sort_order as "sortOrder", is_active as "isActive", + created_at as "createdAt", updated_at as "updatedAt" + `, + [ + auth.tenantId, + nullableString(body.id), + regionId, + schoolId, + nullableString(body.legacyId), + requiredString(body, 'examName'), + nullableString(body.examDate), + nullableString(body.examType), + nullableString(body.description), + JSON.stringify(objectValue(body.metadata)), + intValue(body.sortOrder, 0), + boolValue(body.isActive, true), + ], + ); + if (!result.rows[0]) throw new HttpError(404, 'Exam date not found for this tenant', 'EXAM_DATE_NOT_FOUND'); + await recordAudit(client, auth, 'tenant.exam_date.upserted', 'exam_dates', result.rows[0].id, { + examName: result.rows[0].examName, + examDate: result.rows[0].examDate, + }); + return result.rows[0]; + }); + + return { item }; +} + +export async function tenantFeedbacksRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'feedback:read'); + const limit = intParam(ctx, 'limit', 100, 500); + const status = stringParam(ctx, 'status'); + const type = stringParam(ctx, 'type'); + const questionId = stringParam(ctx, 'questionId'); + const userId = stringParam(ctx, 'userId'); + const params: unknown[] = [auth.tenantId]; + const filters = ['r.tenant_id = $1']; + if (status) { + if (!REPORT_STATUSES.includes(status)) throw new HttpError(400, `Invalid report status: ${status}`, 'INVALID_REPORT_STATUS'); + params.push(status); + filters.push(`r.status = $${params.length}`); + } + if (type) { + params.push(type); + filters.push(`r.type = $${params.length}`); + } + if (questionId) { + params.push(questionId); + filters.push(`r.question_id = $${params.length}::uuid`); + } + if (userId) { + params.push(userId); + filters.push(`r.user_id = $${params.length}::uuid`); + } + params.push(limit); + + const items = await query( + ` + select r.id, r.question_id as "questionId", q.type as "questionType", + r.user_id as "userId", u.name as "userName", u.phone as "userPhone", + r.type, r.category, r.title, r.description, r.status, r.priority, + r.contact, r.attachments, r.metadata, r.resolution, + r.handled_by as "handledBy", handler.name as "handledByName", + r.handled_at as "handledAt", r.created_at as "createdAt", r.updated_at as "updatedAt" + from public.reports r + left join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id + left join public.platform_users u on u.id = r.user_id + left join public.platform_users handler on handler.id = r.handled_by + where ${filters.join(' and ')} + order by case r.priority + when 'urgent' then 1 + when 'high' then 2 + when 'normal' then 3 + else 4 + end, r.created_at desc + limit $${params.length} + `, + params, + ); + + return { items }; +} + +export async function tenantFeedbackEventsRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'feedback:read'); + const reportId = stringParam(ctx, 'reportId'); + if (!reportId) throw new HttpError(400, 'reportId is required', 'REPORT_ID_REQUIRED'); + const items = await query( + ` + select e.id, e.report_id as "reportId", e.from_status as "fromStatus", + e.to_status as "toStatus", e.note, e.actor_user_id as "actorUserId", + actor.name as "actorName", e.metadata, e.created_at as "createdAt" + from public.report_status_events e + left join public.platform_users actor on actor.id = e.actor_user_id + where e.tenant_id = $1 and e.report_id = $2 + order by e.created_at asc + `, + [auth.tenantId, reportId], + ); + return { items }; +} + +export async function updateTenantFeedbackStatusRoute(ctx: RequestContext) { + const auth = await requireTenantAdmin(ctx); + requireTenantPermission(auth, 'feedback:write'); + const body = await readJsonBody(ctx); + const reportId = requiredString(body, 'reportId'); + const nextStatus = optionalChoice(body.status, REPORT_STATUSES, 'accepted'); + const rewardPoints = Math.max(0, intValue(body.rewardPoints, 0)); + + const item = await transaction(async client => { + const current = await client.query<{ status: string; userId: string | null }>( + ` + select status, user_id as "userId" + from public.reports + where tenant_id = $1 and id = $2 + limit 1 + `, + [auth.tenantId, reportId], + ); + if (!current.rows[0]) throw new HttpError(404, 'Feedback report not found', 'REPORT_NOT_FOUND'); + + const result = await client.query( + ` + update public.reports + set status = $3, + priority = $4, + resolution = coalesce($5, resolution), + handled_by = $6, + handled_at = now(), + updated_at = now() + where tenant_id = $1 and id = $2 + returning id, question_id as "questionId", user_id as "userId", type, + title, description, status, priority, resolution, + handled_by as "handledBy", handled_at as "handledAt", + updated_at as "updatedAt" + `, + [ + auth.tenantId, + reportId, + nextStatus, + optionalChoice(body.priority, REPORT_PRIORITIES, 'normal'), + nullableString(body.resolution), + auth.userId, + ], + ); + + await client.query( + ` + insert into public.report_status_events ( + tenant_id, report_id, from_status, to_status, note, actor_user_id, metadata + ) + values ($1, $2, $3, $4, $5, $6, $7::jsonb) + `, + [ + auth.tenantId, + reportId, + current.rows[0].status, + nextStatus, + nullableString(body.note), + auth.userId, + JSON.stringify(objectValue(body.metadata)), + ], + ); + + let reward = null; + if (rewardPoints > 0 && current.rows[0].userId) { + const currentScore = await client.query<{ score: number }>( + 'select score from public.platform_users where id = $1 for update', + [current.rows[0].userId], + ); + const balanceAfter = Number(currentScore.rows[0]?.score || 0) + rewardPoints; + const ledger = await client.query( + ` + insert into public.user_score_events ( + tenant_id, user_id, event_type, points, balance_after, + source_type, source_id, idempotency_key, metadata + ) + values ($1, $2, 'feedback_reward', $3, $4, 'reports', $5, $6, $7::jsonb) + on conflict (tenant_id, idempotency_key) do nothing + returning id, event_type as "eventType", points, balance_after as "balanceAfter", + source_type as "sourceType", source_id as "sourceId", created_at as "createdAt" + `, + [ + auth.tenantId, + current.rows[0].userId, + rewardPoints, + balanceAfter, + reportId, + `feedback_reward:${reportId}`, + JSON.stringify({ reportId, status: nextStatus }), + ], + ); + if (ledger.rows[0]) { + const updatedUser = await client.query<{ score: number }>( + ` + update public.platform_users + set score = score + $2, + updated_at = now() + where id = $1 + returning score + `, + [current.rows[0].userId, rewardPoints], + ); + reward = { ...ledger.rows[0], balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter }; + } + } + + await recordAudit(client, auth, 'tenant.feedback.status_updated', 'reports', reportId, { + fromStatus: current.rows[0].status, + toStatus: nextStatus, + rewardPoints, + }); + return { ...result.rows[0], reward }; + }); + + return { item }; +} diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index d0e47c95..a7d91d31 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -63,6 +63,9 @@ | 模考交卷报告 | 可联调 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports`;后端按 session 快照评分、分段统计、错题解析汇总,重复提交幂等 | | 学习历史/统计/趋势 | 可联调 | `GET /api/learning/practice-sessions/history`、`GET /api/learning/stats`、`GET /api/learning/trend`;可支撑个人中心、练习历史、正确率趋势和题型分布 | | 错题复习计划 | 可联调 | `GET /api/learning/wrong-questions/review-plan` + `POST /api/learning/practice-sessions` 的 `mode=wrong_review`,后端从本人错题本安全组卷 | +| 考试倒计时 | 可联调 | `GET /api/catalog/exam-dates`、`GET /api/profile/exam-countdowns`;返回租户/地区匹配考试日期和 `daysLeft` | +| 题目反馈/纠错 | 可联调 | `GET/POST /api/profile/feedbacks`,题目必须属于当前租户;租户后台可处理状态流转 | +| 签到积分 | 可联调 | `POST /api/profile/check-in`、`GET /api/profile/score-events`;积分流水幂等、事务加锁,重复签到不重复加分 | ## 背单词、知识手册、分数线、视频 @@ -114,6 +117,8 @@ | 登录 provider 配置 | 可联调 | `/api/tenant-admin/auth-providers` | | 密钥掩码/引用 | 迁移期 | API 有掩码,生产前要做 KMS/Vault 或 envelope encryption | | 活动、Banner、FAQ、公告 | 可联调 | `/api/tenant-admin/banners`、`faqs`、`announcements` | +| 考试日期维护 | 可联调 | `/api/tenant-admin/exam-dates`,支持地区维度维护和公开倒计时展示 | +| 题目反馈处理 | 可联调 | `/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`,支持班级范围权限和审计 | diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index 1b93f663..9b6c0c03 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -6,21 +6,22 @@ - API Docker 镜像 `tiku-saas-dev-api:latest` 已可构建,并可从容器连接宿主 Supabase PostgreSQL。 - API 已按 `core/features` 分层: - `auth`:短信验证码登录、迁移期 session、OAuth provider 预留。 - - `catalog`:公开题库、地区、内容入口、分类树、题目集合、练习蓝图、手册、商品、SVIP 套餐、资料资源只读/下载接口。 + - `catalog`:公开题库、地区、内容入口、分类树、题目集合、练习蓝图、考试日期、手册、商品、SVIP 套餐、资料资源只读/下载接口。 - `learning`:顺序/随机/全真模拟组卷 session、答题记录、错题、收藏、背单词进度/收藏/统计。 - - `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习。 + - `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习、考试倒计时、签到积分、题目反馈。 - `scoreline`:分数线字段、院校、专业、记录、趋势、年份。 - `video`:题目视频讲解、批量预加载、通用视频搜索。 - `commerce`:订单、支付确认、激活码兑换、权益查询。 - `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` 支持每日签到积分流水和反馈奖励幂等。 - `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 - 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。 @@ -61,6 +62,7 @@ GET /api/catalog/question-collections/questions GET /api/catalog/practice-blueprints GET /api/catalog/assets GET /api/catalog/assets/download +GET /api/catalog/exam-dates POST /api/learning/answers GET /api/learning/favorites/questions POST /api/learning/favorites/questions @@ -72,6 +74,11 @@ POST /api/learning/vocabulary/favorites GET /api/learning/vocabulary/stats GET /api/profile/me PATCH /api/profile/me +POST /api/profile/check-in +GET /api/profile/score-events +GET /api/profile/feedbacks +POST /api/profile/feedbacks +GET /api/profile/exam-countdowns GET /api/scoreline/fields GET /api/scoreline/schools GET /api/scoreline/majors @@ -173,6 +180,11 @@ GET /api/tenant-admin/faqs PUT /api/tenant-admin/faqs GET /api/tenant-admin/announcements PUT /api/tenant-admin/announcements +GET /api/tenant-admin/exam-dates +PUT /api/tenant-admin/exam-dates +GET /api/tenant-admin/feedbacks +POST /api/tenant-admin/feedbacks/status +GET /api/tenant-admin/feedbacks/events GET /api/tenant-admin/code-batches PUT /api/tenant-admin/code-batches GET /api/tenant-admin/activation-codes diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index 13c19a02..d13c4369 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -18,10 +18,10 @@ | --- | --- | --- | --- | | 登录/注册 | `pages/Login.tsx` | 部分覆盖 | 短信、Supabase JWT、微信小程序登录主链路已有;微信网页登录、QQ OAuth、手机号换绑/补绑和生产账号联调待补 | | 选地区 | `pages/RegionSelector.tsx` | 已覆盖 | 需要前端按租户套餐和权益展示可选地区 | -| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、FAQ、时间线、入口、个人统计有基础;缺考试倒计时 API、完整运营动态和学习任务聚合 | +| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、FAQ、时间线、考试倒计时、入口、个人统计有基础;缺完整运营动态和学习任务聚合 | | 题库入口 | `pages/SubjectSelector.tsx`、`RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` | | 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 | -| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验、session 快照、练习历史和趋势统计已由后端强制;继续补断点续练和题目反馈 | +| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验、session 快照、练习历史、趋势统计和题目反馈已由后端强制;继续补断点续练 | | 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照、访问控制和历史统计,前端需按 mode 调用 | | 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、交卷评分、分段统计和错题解析汇总已覆盖;后续补排行榜/排名、断点续练、复盘体验 | | 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 错题列表、移出错题、复习计划和 `wrong_review` 后端组卷已覆盖;后续补更细的间隔复习算法 | @@ -31,18 +31,18 @@ | 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 | | 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 | | 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐/订单/权益/激活码、微信支付/支付宝 provider 主链路已有;缺优惠券下单抵扣、订单状态轮询、激活码预检查、退款/对账/补偿任务 | -| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计和趋势已有;缺勋章 API、签到积分、考试倒计时、账号绑定/换绑、学习报告可视化 | +| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计、签到积分、考试倒计时和趋势已有;缺勋章 API、账号绑定/换绑、学习报告可视化 | | 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账/签名下载已有;缺 PDF 预览、水印、防盗链和上传后对象校验 | | AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 | -| 题目反馈 | `02-API接口.md` 用户反馈 | 未覆盖 | 需新增题目反馈表、提交接口、租户后台处理流和通知 | -| 签到积分 | `Profile.tsx`、`02-API接口.md` | 未覆盖 | 旧用户表有 `lastCheckInDate/score`;新 schema 有字段基础,但缺签到 API、积分流水和活动规则 | +| 题目反馈 | `02-API接口.md` 用户反馈 | 部分覆盖 | 学生提交、本人列表、租户后台处理、状态事件、反馈奖励积分已覆盖;缺处理通知、前端消息提醒和批量统计 | +| 签到积分 | `Profile.tsx`、`02-API接口.md` | 部分覆盖 | 每日签到、连续签到基础、积分流水、重复签到幂等已覆盖;缺积分兑换、活动任务和更完整的运营规则 | | 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 未覆盖 | 需新增题库/模考/背单词排行榜聚合接口和防刷策略 | ## 租户后台功能 | 旧功能/组件 | 新后端状态 | 待补齐 | | --- | --- | --- | -| 用户管理 | 本地待推 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务已在本地实现;批量 CRM 推送、补绑、学习督导自动化待补 | +| 用户管理 | 已覆盖 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务已实现;批量 CRM 推送、补绑、学习督导自动化待补 | | 销售/代理管理 | 部分覆盖 | referral/team/stats 有;缺分佣比例、结算单、审核、导出 | | 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 | | 数据看板 | 部分覆盖 | 表基础有;缺收益、注册、答题、活跃、套餐销量等聚合 API | @@ -53,6 +53,7 @@ | 商户收款配置 | 部分覆盖 | 配置 API 有;缺真实支付 provider 和验签 | | 登录配置 | 部分覆盖 | 配置 API 有;缺真实短信/OAuth provider 实现 | | Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 | +| 考试日期/倒计时 | 已覆盖 | 租户后台维护、学生端和公开目录查询已有;前端需展示地区/院校匹配结果 | | SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI | | 优惠券 | 部分覆盖 | 后台配置有;前台兑换、下单抵扣待补 | | 激活码 | 已覆盖 | 批次、生成、兑换主链路已有 | @@ -94,16 +95,15 @@ 这些是旧项目中已经出现过、但新后端还没有完整业务闭环的功能: -1. 题目反馈:学生提交题目纠错、租户后台处理、状态流转、处理通知。 -2. 签到积分:每日签到、积分流水、连续签到、积分和活动/兑换的关系。 -3. 排行榜:刷题、模考、背单词排行榜,以及防刷、租户/地区/班级维度。 -4. 考试倒计时:`exam_dates` 表已有,但还缺学生端查询和后台维护 API。 -5. 订单状态轮询和激活码预检查:订单列表和兑换已有,但旧商城体验需要更细的状态查询/预检接口。 -6. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。 -7. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。 -8. 导入扩展:Excel/CSV、分数线、视频批量导入和大批量异步 worker。 -9. 公共题库商业化:平台公共/地区题库披露、租户采纳、套餐授权、版本同步。 -10. CRM/销售结算:真实 CRM worker、轮询/定向分配、分佣规则、结算单、审核和导出。 +1. 排行榜:刷题、模考、背单词排行榜,以及防刷、租户/地区/班级维度。 +2. 订单状态轮询和激活码预检查:订单列表和兑换已有,但旧商城体验需要更细的状态查询/预检接口。 +3. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。 +4. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。 +5. 导入扩展:Excel/CSV、分数线、视频批量导入和大批量异步 worker。 +6. 公共题库商业化:平台公共/地区题库披露、租户采纳、套餐授权、版本同步。 +7. CRM/销售结算:真实 CRM worker、轮询/定向分配、分佣规则、结算单、审核和导出。 +8. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 +9. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。 ### P0:前端联调到云端前 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 908394d1..447f1e46 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、私密密钥、活动、考试日期、题目反馈处理、激活码、优惠券、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。 - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 @@ -19,6 +19,7 @@ - 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。 - 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。 - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;后续补批量 CRM 推送和自动学习督导。 +- 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水已完成接口和集成测试。 - 本地验证:`npm run check:refactor` 已通过。 当前更适合进入前端联调前阅读的总览文档: @@ -85,16 +86,25 @@ - 继续补排行榜。 - 继续补模考排名、断点续练、复盘体验。 -7. 数据看板 +7. 订单和激活码体验 + - 补订单详情、订单状态轮询、支付状态刷新。 + - 补激活码预检查,兑换前展示可用地区、天数、是否绑定代理/销售。 + - 补优惠券前台兑换和下单抵扣计算。 + +8. 积分和反馈增强 + - 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。 + - 继续补积分兑换、活动任务、连续签到奖励配置、处理通知和反馈聚合统计。 + +9. 数据看板 - 收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量。 - 套餐销量、运营动态、24h 活跃度、激活码使用情况。 - 销售/代理转化、分佣结算、客资跟进效果。 -8. 学生运营管理 +10. 学生运营管理 - 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。 - 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和前端 UI。 -9. AI 择校推荐 +11. AI 择校推荐 - 地区考试数据上下文。 - 学生输入 schema。 - AI 返回 JSON schema。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 7bfe7af4..03d4c91e 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -147,7 +147,7 @@ tenant::theme | --- | --- | | 启动页 | `GET /api/tenant/resolve` | | 登录页 | `POST /api/auth/sms/send`、`POST /api/auth/sms/verify`、`POST /api/auth/oauth/wechat-miniapp`、后续微信网页/QQ provider | -| 首页 | `/api/catalog/content-entries`、`/api/catalog/banners`、`/api/catalog/announcements`、`/api/profile/me` | +| 首页 | `/api/catalog/content-entries`、`/api/catalog/banners`、`/api/catalog/announcements`、`/api/catalog/exam-dates`、`/api/profile/me` | | 选地区 | `/api/catalog/regions`、`/api/commerce/entitlements/check` | | 题库入口 | `/api/catalog/content-entries` | | 分类树 | `/api/catalog/content-nodes?entryId=...&parentId=root` | @@ -160,6 +160,7 @@ tenant::theme | 收藏夹 | `GET/POST /api/learning/favorites/questions` | | 练习历史/统计 | `GET /api/learning/practice-sessions/history`、`GET /api/learning/stats`、`GET /api/learning/trend` | | 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch`、`POST /api/videos/play` | +| 题目反馈 | `POST /api/profile/feedbacks`、`GET /api/profile/feedbacks` | | 背单词 | `/api/catalog/vocabulary-units`、`/api/catalog/vocabulary-words` | | 单词进度/计划 | `/api/learning/vocabulary/progress`、`/api/learning/vocabulary/stats`、`/api/learning/vocabulary/review-plan`、`POST /api/learning/vocabulary/review` | | 单词收藏 | `/api/learning/vocabulary/favorites` | @@ -169,7 +170,7 @@ tenant::theme | 商城 | `/api/catalog/svip-plans`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` | | 订单/权益 | `/api/commerce/orders`、`/api/commerce/entitlements` | | 激活码兑换 | `POST /api/commerce/activation-codes/redeem` | -| 个人中心 | `GET/PATCH /api/profile/me` | +| 个人中心 | `GET/PATCH /api/profile/me`、`POST /api/profile/check-in`、`GET /api/profile/score-events`、`GET /api/profile/exam-countdowns` | | 销售分享 | `/api/referral/resolve`、`track-event`、`bind` | | 租户班级 | `GET/PUT /api/tenant-admin/classes`、`POST /api/tenant-admin/classes/disable` | | 班级成员 | `GET/PUT /api/tenant-admin/classes/members`、`POST /api/tenant-admin/classes/members/remove`、`POST /api/tenant-admin/classes/members/bulk-assign` | @@ -177,6 +178,8 @@ tenant::theme | 学生备注 | `GET/PUT /api/tenant-admin/students/notes` | | 学生跟进任务 | `GET/PUT /api/tenant-admin/students/followups` | | 租户教师 | `GET /api/tenant-admin/teachers` | +| 租户考试日期 | `GET/PUT /api/tenant-admin/exam-dates` | +| 租户反馈处理 | `GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` | ## 练习访问控制契约 @@ -416,6 +419,56 @@ GET /api/learning/vocabulary/review-plan?unitId=&reviewLimit=30&newLimit - 签名 URL 过期后必须重新调用 `/api/videos/play`,不要重试旧 URL。 - 小程序/H5 不保存对象存储真实 key,不把播放 URL 写入本地持久缓存。 +## 考试倒计时、签到积分和反馈 + +首页可用 `GET /api/catalog/exam-dates?regionId=` 展示地区公开考试日期;个人中心优先用 `GET /api/profile/exam-countdowns`,后端会按学生当前 `regionId/selectedSchoolId` 返回匹配倒计时。 + +签到入口调用: + +```http +POST /api/profile/check-in +``` + +返回关键字段: + +```json +{ + "item": { + "checkedIn": true, + "alreadyCheckedIn": false, + "pointsAdded": 10, + "streak": 1, + "score": 10, + "lastCheckInDate": "2026-06-29" + } +} +``` + +前端处理规则: + +- `alreadyCheckedIn=true` 时展示今日已签到,不要本地再加分。 +- 积分明细调用 `GET /api/profile/score-events`。 +- 积分最终余额以后端 `score` 和流水为准,前端只做展示。 + +题目页、资料页或视频页可提交反馈: + +```json +{ + "questionId": "...", + "type": "question_error", + "category": "answer", + "title": "题目解析有误", + "description": "请填写具体问题", + "attachments": [] +} +``` + +前端处理规则: + +- `questionId` 如存在,后端会校验题目必须属于当前租户。 +- 反馈状态由租户后台处理,学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。 +- 租户后台处理反馈时,奖励积分由后端 `idempotency_key` 保证不会重复发放,前端不要重复叠加。 + ## 题库新模型接入方式 旧项目常按“地区 -> 科目 -> 章节/试卷”固定层级处理。新项目不要写死层级,按下面模型渲染: @@ -618,6 +671,8 @@ GET /api/commerce/entitlements - 题目/单词/知识手册/分数线/视频维护 - JSON 导入 preview/import/issues - Banner/FAQ/公告/激活码/优惠券 +- 考试日期:`GET/PUT /api/tenant-admin/exam-dates` +- 题目反馈:`GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` - 销售/代理/CRM 队列 租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 permission keys 执行真正的访问控制。班级/学生范围权限由后端根据角色、模板 `dataScope.classIds` 和 `tenant_class_members` 计算,教师默认只能看到自己负责班级。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index e0b50f0d..5779d1e3 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -44,6 +44,8 @@ const ids = { scorelineSchool: '00000000-0000-0000-0000-000000000831', tenantClass: '00000000-0000-0000-0000-000000000851', tenantClassOther: '00000000-0000-0000-0000-000000000852', + examDate: '00000000-0000-0000-0000-000000000861', + tenantExamDate: '00000000-0000-0000-0000-000000000862', }; const paymentFixture = (() => { @@ -677,6 +679,13 @@ async function testCatalogAndLearning() { }); assert.ok(blueprintList.items?.some(item => item.id === ids.practiceBlueprintMock), 'catalog should expose mock exam blueprint'); + const examDates = await request('/api/catalog/exam-dates', { + query: { regionId: ids.region }, + }); + const smokeExamDate = examDates.items?.find(item => item.id === ids.examDate); + assert.equal(smokeExamDate?.examName, '烟测统考', 'catalog should expose tenant exam date'); + assert.equal(typeof smokeExamDate?.daysLeft, 'number', 'catalog exam date should include countdown days'); + const questionsByNode = await request('/api/catalog/questions', { query: { contentNodeId: ids.contentNodeSchoolTarget, limit: 20 }, }); @@ -931,6 +940,58 @@ async function testProfile() { assert.equal(payload.item?.userId, USER_ID, 'profile should belong to smoke user'); assert.ok(payload.item?.stats?.vocabulary?.totalWords >= 1, 'profile should include vocabulary stats'); assert.ok(Array.isArray(payload.item?.recentPractices), 'profile should include recent practices'); + + const countdowns = await request('/api/profile/exam-countdowns'); + const profileExamDate = countdowns.items?.find(item => item.id === ids.examDate); + assert.equal(profileExamDate?.examName, '烟测统考', 'profile countdown should include tenant exam date'); + assert.equal(typeof profileExamDate?.daysLeft, 'number', 'profile countdown should include daysLeft'); + assert.equal(countdowns.target?.regionId, ids.region, 'profile countdown should use student target region'); + + const checkIn = await request('/api/profile/check-in', { method: 'POST' }); + assert.equal(checkIn.item?.checkedIn, true, 'student should be able to check in'); + assert.ok(checkIn.item?.pointsAdded >= 10, 'check-in should add points'); + assert.ok(checkIn.item?.score >= checkIn.item?.pointsAdded, 'check-in should return updated score'); + assert.equal(checkIn.item?.ledger?.eventType, 'check_in', 'check-in should write score ledger'); + + const duplicateCheckIn = await request('/api/profile/check-in', { method: 'POST' }); + assert.equal(duplicateCheckIn.item?.checkedIn, false, 'duplicate daily check-in should be idempotent'); + assert.equal(duplicateCheckIn.item?.alreadyCheckedIn, true, 'duplicate daily check-in should report already checked in'); + assert.equal(duplicateCheckIn.item?.pointsAdded, 0, 'duplicate daily check-in should not add points'); + + const scoreEvents = await request('/api/profile/score-events', { query: { limit: 20 } }); + assert.ok(scoreEvents.items?.some(item => item.eventType === 'check_in'), 'score ledger should include check-in event'); + + const feedback = await request('/api/profile/feedbacks', { + method: 'POST', + body: { + questionId: ids.question, + type: 'question_error', + category: 'answer', + title: '集成测试题目纠错', + description: '这道烟测题目的解析需要租户后台核对。', + contact: 'student@example.test', + metadata: { source: 'api-integration-test' }, + }, + }); + assert.equal(feedback.item?.status, 'pending', 'student feedback should start as pending'); + assert.equal(feedback.item?.questionId, ids.question, 'student feedback should bind tenant question'); + + const feedbackList = await request('/api/profile/feedbacks', { query: { status: 'pending', limit: 20 } }); + assert.ok(feedbackList.items?.some(item => item.id === feedback.item.id), 'profile feedback list should include submitted feedback'); + + const trustedLogin = await loginBySms('13800000000'); + const crossTenantFeedback = await request('/api/profile/feedbacks', { + tenantId: PARTNER_TENANT_ID, + userId: false, + headers: { authorization: `Bearer ${trustedLogin.session.token}` }, + method: 'POST', + body: { + questionId: ids.question, + description: '不应跨租户提交旧租户题目纠错。', + }, + expectStatus: 403, + }); + assert.equal(crossTenantFeedback.code, 'AUTH_TENANT_MISMATCH', 'feedback submission must use authenticated tenant context'); } async function testScoreline() { @@ -2273,11 +2334,112 @@ async function testTenantAdminOps() { }); assert.equal(announcement.item?.content, '集成测试公告', 'tenant admin should upsert announcement'); + const examDate = await request('/api/tenant-admin/exam-dates', { + userId: TENANT_ADMIN_USER_ID, + method: 'PUT', + body: { + id: ids.tenantExamDate, + regionId: ids.region, + legacyId: 'integration-exam-date', + examName: '集成测试考试', + examDate: '2030-07-01', + examType: 'school', + description: '租户后台维护的考试日期', + metadata: { source: 'integration-test' }, + sortOrder: 2, + isActive: true, + }, + }); + assert.equal(examDate.item?.id, ids.tenantExamDate, 'tenant admin should upsert exam date'); + + const examDates = await request('/api/tenant-admin/exam-dates', { + userId: TENANT_ADMIN_USER_ID, + query: { regionId: ids.region, limit: 20 }, + }); + assert.ok(examDates.items?.some(item => item.id === ids.tenantExamDate), 'tenant admin should list tenant exam date'); + + const studentExamDateDenied = await request('/api/tenant-admin/exam-dates', { + expectStatus: 403, + }); + assert.equal(studentExamDateDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant exam date admin'); + const publicBanners = await request('/api/catalog/banners', { query: { regionId: ids.region }, }); assert.ok(publicBanners.items?.some(item => item.title === '集成测试活动'), 'public catalog should expose active tenant banner'); + const publicExamDates = await request('/api/catalog/exam-dates', { + query: { regionId: ids.region, limit: 20 }, + }); + assert.ok(publicExamDates.items?.some(item => item.id === ids.tenantExamDate), 'public catalog should expose tenant admin exam date'); + + const feedbacksBefore = await request('/api/tenant-admin/feedbacks', { + userId: TENANT_ADMIN_USER_ID, + query: { status: 'pending', limit: 20 }, + }); + const pendingFeedback = feedbacksBefore.items?.find(item => item.title === '集成测试题目纠错'); + assert.ok(pendingFeedback?.id, 'tenant admin should list student feedback'); + + const studentFeedbackDenied = await request('/api/tenant-admin/feedbacks', { + expectStatus: 403, + }); + assert.equal(studentFeedbackDenied.code, 'TENANT_ADMIN_REQUIRED', 'student should not access tenant feedback admin'); + + const resolvedFeedback = await request('/api/tenant-admin/feedbacks/status', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + reportId: pendingFeedback.id, + status: 'resolved', + priority: 'high', + resolution: '已核对并修正解析。', + note: '集成测试处理完成', + rewardPoints: 5, + metadata: { source: 'integration-test' }, + }, + }); + assert.equal(resolvedFeedback.item?.status, 'resolved', 'tenant admin should resolve feedback'); + assert.equal(resolvedFeedback.item?.reward?.eventType, 'feedback_reward', 'feedback reward should write score ledger'); + + 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, + ).length || 0; + assert.equal(rewardCountAfterResolve, 1, 'feedback reward should be recorded once after first resolution'); + + const repeatedReward = await request('/api/tenant-admin/feedbacks/status', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + reportId: pendingFeedback.id, + status: 'resolved', + priority: 'high', + resolution: '重复处理不应重复发积分。', + rewardPoints: 5, + }, + }); + assert.equal(repeatedReward.item?.reward, null, 'repeated feedback reward should be idempotent'); + + const rewardEventsAfterRepeat = await request('/api/profile/score-events', { query: { limit: 50 } }); + const rewardCountAfterRepeat = rewardEventsAfterRepeat.items?.filter( + item => item.eventType === 'feedback_reward' && item.sourceId === pendingFeedback.id, + ).length || 0; + assert.equal(rewardCountAfterRepeat, 1, 'feedback reward should not duplicate on repeated status updates'); + + const feedbackEvents = await request('/api/tenant-admin/feedbacks/events', { + userId: TENANT_ADMIN_USER_ID, + query: { reportId: pendingFeedback.id }, + }); + assert.ok(feedbackEvents.items?.some(item => item.toStatus === 'pending'), 'feedback events should include initial pending event'); + assert.ok(feedbackEvents.items?.some(item => item.toStatus === 'resolved'), 'feedback events should include resolved event'); + + const partnerFeedbackDenied = await request('/api/tenant-admin/feedbacks', { + tenantId: PARTNER_TENANT_ID, + userId: TENANT_ADMIN_USER_ID, + expectStatus: 403, + }); + assert.equal(partnerFeedbackDenied.code, 'TENANT_ADMIN_REQUIRED', 'feedback admin must be tenant isolated'); + const batch = await request('/api/tenant-admin/code-batches', { userId: TENANT_ADMIN_USER_ID, method: 'PUT', diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index 857dd974..112a56ff 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -54,6 +54,8 @@ const ids = { recentPractice: '00000000-0000-0000-0000-000000000841', tenantClass: '00000000-0000-0000-0000-000000000851', tenantClassOther: '00000000-0000-0000-0000-000000000852', + examDate: '00000000-0000-0000-0000-000000000861', + examDateSchool: '00000000-0000-0000-0000-000000000862', partnerTenant: '00000000-0000-0000-0000-000000000901', partnerSubscription: '00000000-0000-0000-0000-000000000902', partnerInvoice: '00000000-0000-0000-0000-000000000903', @@ -69,6 +71,43 @@ async function main() { try { await client.query('begin'); + await client.query( + ` + delete from public.report_status_events + where tenant_id = $1 + and report_id in ( + select id from public.reports + where tenant_id = $1 + and ( + user_id in ($2::uuid, $3::uuid) + or question_id in ($4::uuid, $5::uuid) + ) + ) + `, + [tenantId, ids.user, ids.tenantAdminUser, ids.question, ids.questionTwo], + ); + + await client.query( + ` + delete from public.reports + where tenant_id = $1 + and ( + user_id in ($2::uuid, $3::uuid) + or question_id in ($4::uuid, $5::uuid) + ) + `, + [tenantId, ids.user, ids.tenantAdminUser, ids.question, ids.questionTwo], + ); + + await client.query( + ` + delete from public.user_score_events + where tenant_id = $1 + and user_id in ($2::uuid, $3::uuid) + `, + [tenantId, ids.user, ids.tenantAdminUser], + ); + await client.query( ` delete from public.crm_webhook_queue @@ -252,6 +291,16 @@ async function main() { [ids.user, ids.authUser], ); + await client.query( + ` + update public.platform_users + set score = 0, + updated_at = now() + where id in ($1::uuid, $2::uuid) + `, + [ids.user, ids.tenantAdminUser], + ); + await client.query( ` insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile) @@ -389,6 +438,45 @@ async function main() { [ids.region, tenantId], ); + await client.query( + ` + update public.student_profiles + set region_id = $3, + last_check_in_date = null, + stats = stats - 'checkInStreak' - 'lastCheckInPoints', + updated_at = now() + where tenant_id = $1 + and user_id in ($2::uuid, $4::uuid) + `, + [tenantId, ids.user, ids.region, ids.secondStudentUser], + ); + + await client.query( + ` + insert into public.exam_dates ( + id, tenant_id, region_id, school_id, legacy_id, exam_name, + exam_date, exam_type, description, metadata, sort_order, is_active + ) + values ( + $1, $2, $3, null, 'smoke-exam-date', '烟测统考', + '2030-06-15', 'unified', '用于验证学生端考试倒计时和租户后台考试日期维护', + '{"source":"smoke-seed"}'::jsonb, 1, true + ) + on conflict (id) + do update set region_id = excluded.region_id, + school_id = excluded.school_id, + exam_name = excluded.exam_name, + exam_date = excluded.exam_date, + exam_type = excluded.exam_type, + description = excluded.description, + metadata = excluded.metadata, + sort_order = excluded.sort_order, + is_active = excluded.is_active, + updated_at = now() + `, + [ids.examDate, tenantId, ids.region], + ); + await client.query( ` insert into public.subjects (id, tenant_id, region_id, legacy_id, name, type, sort_order, is_active) diff --git a/supabase/migrations/202606290004_feedback_checkins_exam_dates.sql b/supabase/migrations/202606290004_feedback_checkins_exam_dates.sql new file mode 100644 index 00000000..146c33e0 --- /dev/null +++ b/supabase/migrations/202606290004_feedback_checkins_exam_dates.sql @@ -0,0 +1,95 @@ +alter table public.exam_dates + add column if not exists description text, + add column if not exists metadata jsonb not null default '{}'::jsonb; + +alter table public.reports + add column if not exists title text, + add column if not exists category text, + add column if not exists priority text not null default 'normal' + check (priority in ('low', 'normal', 'high', 'urgent')), + add column if not exists handled_by uuid references public.platform_users(id) on delete set null, + add column if not exists handled_at timestamptz, + add column if not exists resolution text, + add column if not exists contact text, + add column if not exists attachments jsonb not null default '[]'::jsonb, + add column if not exists metadata jsonb not null default '{}'::jsonb; + +do $$ +begin + if not exists (select 1 from pg_constraint where conname = 'reports_status_check') then + alter table public.reports + add constraint reports_status_check + check (status in ('pending', 'accepted', 'rejected', 'resolved', 'closed')); + end if; + + if not exists (select 1 from pg_constraint where conname = 'reports_type_check') then + alter table public.reports + add constraint reports_type_check + check (type is null or type in ('question_error', 'content_error', 'video_error', 'asset_error', 'system_bug', 'suggestion', 'other')); + end if; +end $$; + +create table if not exists public.report_status_events ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + report_id uuid not null references public.reports(id) on delete cascade, + from_status text, + to_status text not null, + note text, + actor_user_id uuid references public.platform_users(id) on delete set null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +comment on table public.report_status_events is + 'Audit timeline for student feedback/question correction status changes.'; + +create table if not exists public.user_score_events ( + 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, + event_type text not null + check (event_type in ('check_in', 'manual_adjust', 'feedback_reward', 'activity_reward', 'redeem_cost')), + points integer not null, + balance_after integer not null, + source_type text, + source_id uuid, + idempotency_key text, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + unique (tenant_id, idempotency_key) +); + +comment on table public.user_score_events is + 'Tenant-scoped student score ledger. Positive points add score, negative points consume score.'; + +create index if not exists idx_reports_tenant_status + on public.reports(tenant_id, status, created_at desc); + +create index if not exists idx_reports_tenant_question + on public.reports(tenant_id, question_id, created_at desc) + where question_id is not null; + +create index if not exists idx_report_status_events_report + on public.report_status_events(tenant_id, report_id, created_at desc); + +create index if not exists idx_score_events_user + on public.user_score_events(tenant_id, user_id, created_at desc); + +create index if not exists idx_exam_dates_tenant_school + on public.exam_dates(tenant_id, school_id, exam_date); + +alter table public.report_status_events enable row level security; +alter table public.user_score_events enable row level security; + +drop policy if exists tenant_isolation on public.report_status_events; +create policy tenant_isolation on public.report_status_events + for all + using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) + with check (tenant_id = app.current_tenant_id() or app.is_platform_admin()); + +drop policy if exists tenant_isolation on public.user_score_events; +create policy tenant_isolation on public.user_score_events + for all + using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) + with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());