feat: add feedback checkins exam dates

This commit is contained in:
Codex
2026-06-29 01:48:38 +08:00
parent 726d090a8e
commit 8f92d0427e
16 changed files with 1232 additions and 33 deletions

View File

@@ -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、公共题库授权、租户采纳、订单状态轮询、激活码预检查、积分活动深化和排行榜。

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '学生查看' },

View File

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

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

View File

@@ -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`,支持班级范围权限和审计 |

View File

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

View File

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

View File

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

View File

@@ -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` 计算,教师默认只能看到自己负责班级。

View File

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

View File

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

View File

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