forked from wangziqi/gongxue-base
feat: add feedback checkins exam dates
This commit is contained in:
@@ -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、公共题库授权、租户采纳、订单状态轮询、激活码预检查、积分活动深化和排行榜。
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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<string, unknown>>(
|
||||
`
|
||||
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');
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}>(
|
||||
`
|
||||
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<string, unknown>>(
|
||||
`
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: '学生查看' },
|
||||
|
||||
@@ -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],
|
||||
|
||||
369
apps/api/src/features/tenant-admin/operations.ts
Normal file
369
apps/api/src/features/tenant-admin/operations.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
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<string, unknown> = {},
|
||||
) {
|
||||
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 };
|
||||
}
|
||||
@@ -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`,支持班级范围权限和审计 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:前端联调到云端前
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -147,7 +147,7 @@ tenant:<tenantId>: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:<tenantId>: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:<tenantId>: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:<tenantId>: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=<unitId>&reviewLimit=30&newLimit
|
||||
- 签名 URL 过期后必须重新调用 `/api/videos/play`,不要重试旧 URL。
|
||||
- 小程序/H5 不保存对象存储真实 key,不把播放 URL 写入本地持久缓存。
|
||||
|
||||
## 考试倒计时、签到积分和反馈
|
||||
|
||||
首页可用 `GET /api/catalog/exam-dates?regionId=<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` 计算,教师默认只能看到自己负责班级。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
Reference in New Issue
Block a user