| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 |
- #!/usr/bin/env node
- import { randomBytes } from 'node:crypto';
- const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
- const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
- const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
- const FUNCTION_URL = (process.env.XIAOSHU_FUNCTION_URL || 'https://server.xiaoshu.pro/api/functions').replace(/\/$/, '');
- if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
- async function request(url, init = {}, master = false) {
- const response = await fetch(url, { ...init, headers: { 'X-Parse-Application-Id': APP_ID, ...(master ? { 'X-Parse-Master-Key': MASTER_KEY } : {}), 'Content-Type': 'application/json', ...(init.headers || {}) } });
- const payload = await response.json().catch(() => ({}));
- if (!response.ok) throw new Error(`${response.status}: ${payload.message || payload.error || JSON.stringify(payload)}`);
- return payload;
- }
- async function call(path, token, params, expectedStatus = 200) {
- const startedAt = Date.now();
- const label = params.operation || params.action || path;
- console.error(`[smoke] ${label} ...`);
- const response = await fetch(`${FUNCTION_URL}/${path}`, { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, params }), signal: AbortSignal.timeout(90_000) });
- const payload = await response.json().catch(() => ({}));
- if (response.status !== expectedStatus) throw new Error(`${path} 期望 ${expectedStatus},实际 ${response.status}: ${payload.message || payload.error || ''}`);
- if (expectedStatus === 200 && payload.success !== true) throw new Error(payload.message || `${path} 返回失败`);
- console.error(`[smoke] ${label} ${response.status} ${Date.now() - startedAt}ms`);
- return payload;
- }
- let userId = '';
- try {
- const companies = await request(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
- const companyId = companies.results?.[0]?.objectId;
- if (!companyId) throw new Error('没有可用于运营网关测试的帐套');
- const company = { __type: 'Pointer', className: 'Company', objectId: companyId };
- const username = `codex_ops_smoke_${Date.now()}`;
- const password = randomBytes(24).toString('base64url');
- const created = await request(`${PARSE_URL}/users`, { method: 'POST', body: JSON.stringify({ username, password, isAdmin: true, roles: ['admin', 'ops-manager'], company }) }, true);
- userId = created.objectId;
- const login = await request(`${PARSE_URL}/login`, { method: 'POST', body: JSON.stringify({ username, password }) });
- const token = login.sessionToken;
- if (!token) throw new Error('临时运营管理员未返回会话');
- const ops = (params, expectedStatus = 200) => call('xiaoshu/ops/gateway-v3', token, params, expectedStatus);
- const dashboard = (await ops({ operation: 'ops/dashboard/summary' })).data;
- if (dashboard.identity?.operationsRole !== 'ops-manager' || !Array.isArray(dashboard.metrics) || dashboard.metrics.length < 4) throw new Error('运营工作台返回结构无效');
- if (dashboard.scheduleTrend?.period !== 'week' || dashboard.scheduleTrend?.points?.length !== 7) throw new Error('工作台没有返回最近 7 天排课趋势');
- for (const period of ['month', 'year']) {
- const trend = (await ops({ operation: 'ops/dashboard/schedule-trend', period })).data;
- if (trend.period !== period || !trend.points?.length || typeof trend.todayCourseCount !== 'number' || typeof trend.todayStudentCount !== 'number' || !trend.refreshedAt) throw new Error(`${period} 排课趋势返回结构无效`);
- if (period === 'year' && (trend.points.length <= 12 || trend.points.some((point) => !/^\d{4}-\d{2}-\d{2}$/.test(point.key)))) throw new Error('年趋势没有按天返回数据点');
- }
- const users = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 20, search: '' })).data;
- if (!Array.isArray(users.items) || !users.items.length || users.items.some((item) => 'legacyUserData' in item || 'legacyPasswordHash' in item || 'ACL' in item)) throw new Error('用户业务投影为空或泄漏技术字段');
- if (users.items.some((item) => item.username === username) || users.items.some((item) => !item.learningStatus || !item.learningStatusLabel) || !users.refreshedAt) throw new Error('管理员过滤、学习状态或刷新时间无效');
- if (!users.summary || users.summary.total !== users.total || !Array.isArray(users.agents)) throw new Error('会员摘要或代理筛选项结构无效');
- if (!users.sourceStatus?.available || users.sourceStatus.memberCount !== users.total || users.sourceStatus.total < users.total || users.sourceStatus.storeCount < 1) throw new Error(`会员实时身份源或分类统计无效:${JSON.stringify(users.sourceStatus || {})}`);
- if (users.items.some((item) => item.groupId !== 1 || item.identityType !== 'member' || /陪练/.test(String(item.displayName || '')))) throw new Error('陪练或非会员身份仍混入会员列表');
- if (users.items.some((item) => !item.registeredAt)) throw new Error('会员注册时间存在空白');
- for (let index = 1; index < users.items.length; index += 1) {
- const previous = new Date(users.items[index - 1].registeredAt).getTime();
- const current = new Date(users.items[index].registeredAt).getTime();
- if (previous < current || (previous === current && Number(users.items[index - 1].userId || 0) < Number(users.items[index].userId || 0))) throw new Error('会员列表未按注册时间和会员 ID 倒序稳定排列');
- }
- const learningCounts = {};
- for (const learningStatus of ['trial', 'studying', 'completed', 'not_started']) {
- const result = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 1, search: '', filters: { learningStatus } })).data;
- learningCounts[learningStatus] = result.total;
- }
- if (Object.values(learningCounts).reduce((sum, value) => sum + Number(value || 0), 0) !== users.total) throw new Error(`学习状态分类数量与会员总数不一致:${JSON.stringify(learningCounts)}`);
- const named = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 20, search: '万宇昊' })).data;
- if (!named.items?.some((item) => String(item.displayName || '').includes('万宇昊'))) throw new Error('用户显示名称兜底未找到“万宇昊”');
- const legacyAdmin = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 20, search: 'admin' })).data;
- if (legacyAdmin.items?.some((item) => String(item.username || '').toLowerCase() === 'admin')) throw new Error('Manager 表中的历史管理员仍混入会员列表');
- const identityConflicts = (await ops({ operation: 'ops/users/identity-conflicts', page: 1, pageSize: 100, search: '' })).data;
- if (!Array.isArray(identityConflicts.items) || identityConflicts.items.some((item) => item.currentGroupId !== 1 || item.recommendedGroupId !== 3 || !item.evidence)) throw new Error('会员身份异常预览结构无效');
- const stores = (await ops({ operation: 'ops/stores/list', page: 1, pageSize: 100, search: '', filters: { status: 'all' } })).data;
- if (!Array.isArray(stores.items) || !stores.items.length || !stores.summary || typeof stores.summary.members !== 'number') throw new Error('门店列表或运营摘要结构无效');
- if (!stores.liveSourceAvailable || stores.summary.total !== stores.total || stores.summary.total !== stores.legacyStoreCount || stores.summary.liveOnly !== 0) throw new Error(`门店新数据库投影不完整:${JSON.stringify({ total: stores.total, summary: stores.summary, legacyStoreCount: stores.legacyStoreCount })}`);
- if (stores.items.some((item) => item.readOnly || !['parse', 'hybrid'].includes(String(item.source || '')))) throw new Error('门店仍依赖旧站只读账号');
- if (stores.summary.members + Number(stores.summary.unboundMembers || 0) !== users.total) throw new Error(`门店归属会员与会员管理口径不一致:bound=${stores.summary.members}, unbound=${stores.summary.unboundMembers}, members=${users.total}`);
- const coaches = (await ops({ operation: 'ops/coaches/list', page: 1, pageSize: 20, search: '' })).data;
- if (!Array.isArray(coaches.items) || coaches.items.some((item) => !item.userId || !item.displayName)) throw new Error('陪练老师业务投影无效');
- const activeCoaches = (await ops({ operation: 'ops/coaches/list', page: 1, pageSize: 100, search: '', filters: { status: 'active' } })).data;
- if (!coaches.summary || coaches.summary.total !== coaches.total || coaches.summary.active !== activeCoaches.total || coaches.summary.total < 100 || !Number.isFinite(Number(coaches.summary.monthActive)) || coaches.summary.monthActive < 0) throw new Error(`陪练完整名单、正常账号或本月业务统计无效:${JSON.stringify(coaches.summary || {})}`);
- const legacyCoach1810 = (await ops({ operation: 'ops/coaches/list', page: 1, pageSize: 20, search: '1810', filters: { status: 'all' } })).data.items?.find((item) => Number(item.userId) === 1810);
- if (!legacyCoach1810 || !legacyCoach1810.username || /^\s*陪练\s*1810\s*$/.test(String(legacyCoach1810.displayName || ''))) throw new Error('历史陪练 1810 的实时账号或姓名未正确关联');
- const today = new Date().toISOString().slice(0, 10);
- const schedule = (await ops({ operation: 'ops/schedule/calendar', dateFrom: today, dateTo: today, filters: {} })).data;
- if (!Array.isArray(schedule.appointments) || !Array.isArray(schedule.coaches) || !Array.isArray(schedule.members) || !Array.isArray(schedule.courses) || !schedule.refreshedAt) throw new Error('排课中心日历或业务选项结构无效');
- if (schedule.courses.some((course) => !Array.isArray(course.memberIds)) || !schedule.courses.some((course) => course.memberIds.length > 0)) throw new Error('排课课程没有返回会员绑定范围');
- const memberWithUnboundCourse = schedule.members.filter((member) => Number(member.userId) > 0 && !member.readOnly).map((member) => ({ member, course: schedule.courses.find((course) => Number(course.courseId) > 0 && course.courseId !== 398 && !course.memberIds.includes(member.userId)) })).find((item) => item.course);
- if (memberWithUnboundCourse) {
- const deniedCourse = await ops({ operation: 'ops/appointments/preflight', payload: { studentId: memberWithUnboundCourse.member.userId, courseId: memberWithUnboundCourse.course.courseId, bindingId: memberWithUnboundCourse.course.bindingId, coachId: 0, classType: 1, date: schedule.dataDateTo || today, startTime: '09:00', recurrence: 'once', occurrences: 1 } }, 409);
- if (!/尚未有效开课/.test(String(deniedCourse.message || ''))) throw new Error('排课预检没有阻止选择其他会员的课程');
- }
- const preflightPair = schedule.members.filter((member) => Number(member.userId) > 0 && !member.readOnly).flatMap((member) => schedule.courses.filter((course) => Number(course.courseId) > 0 && course.mappingConfirmed && (course.availableToAll === true || course.memberIds.includes(member.userId))).map((course) => { const classType = course.categoryKey === 'trial' && Number(member.trialPeriods) >= 2 ? 3 : course.supportedDurations?.includes(30) && Number(member.periods30) >= 2 && Number(member.userPoint) >= 1 ? 1 : course.supportedDurations?.includes(60) && Number(member.periods60) >= 2 && Number(member.userPoint) >= 2 ? 2 : 0; return { member, course, classType }; })).find((item) => item.classType);
- if (preflightPair) {
- const preflight = (await ops({ operation: 'ops/appointments/preflight', payload: { studentId: preflightPair.member.userId, courseId: preflightPair.course.courseId, bindingId: preflightPair.course.bindingId, coachId: 0, classType: preflightPair.classType, date: '2099-01-05', startTime: '09:00', recurrence: 'weekly', occurrences: 2 } })).data;
- if (!Array.isArray(preflight.candidates) || !Array.isArray(preflight.conflicts) || !Array.isArray(preflight.conflictIndexes) || typeof preflight.issueCount !== 'number' || preflight.availableCount + preflight.conflictCount !== preflight.candidates.length || preflight.conflictCount !== preflight.conflictIndexes.length) throw new Error('批量排课逐项预检结构或按课次去重统计无效');
- }
- if (!schedule.totalAppointments || !schedule.dataDateFrom || !schedule.dataDateTo) throw new Error(`历史排课总量或数据日期范围缺失:${JSON.stringify({ keys: Object.keys(schedule), totalAppointments: schedule.totalAppointments, dataDateFrom: schedule.dataDateFrom, dataDateTo: schedule.dataDateTo })}`);
- const latestSchedule = (await ops({ operation: 'ops/schedule/calendar', dateFrom: schedule.dataDateTo, dateTo: schedule.dataDateTo, filters: {} })).data;
- if (!latestSchedule.appointments?.length || latestSchedule.totalAppointments !== schedule.totalAppointments) throw new Error('最近历史排课未正确投影到排课中心');
- if (!coaches.items?.some((item) => item.totalAppointments > 0 && item.lastAppointmentAt)) throw new Error('陪练列表缺少历史排课汇总');
- const dashboardCoachMetric = dashboard.metrics.find((item) => item.key === 'coaches');
- if (dashboardCoachMetric?.value !== activeCoaches.total) throw new Error(`工作台陪练人数与陪练页不一致:dashboard=${dashboardCoachMetric?.value}, coaches=${activeCoaches.total}`);
- if (!coaches.liveSourceAvailable || coaches.liveSourceAvailable !== schedule.liveSourceAvailable) throw new Error('陪练页与排课中心没有统一使用新数据库投影');
- const rates = (await ops({ operation: 'ops/pay-rates/list', page: 1, pageSize: 100 })).data;
- for (const expected of [{ type: 1, amount: 20, hours: .5 }, { type: 2, amount: 40, hours: 1 }, { type: 3, amount: 40, hours: 1 }]) {
- if (!rates.items?.some((item) => item.classType === expected.type && item.amount === expected.amount && item.durationHours === expected.hours)) throw new Error(`原系统课酬规则缺失:课型 ${expected.type}`);
- }
- if (rates.items?.some((item) => item.classType === 4 && item.isDefault)) throw new Error('课型4不应存在默认工资规则');
- const teachingStandards = (await ops({ operation: 'ops/teaching-rules/list' })).data;
- const expectedTeachingMatrix = [
- ['word', 30, 15, 20], ['word', 60, 30, 40], ['trial', 60, 30, 40],
- ['primary_writing', 30, 20, 25], ['middle_writing', 30, 25, 30], ['high_writing', 30, 32.5, 37.5],
- ['primary_writing', 60, 40, 50], ['middle_writing', 60, 50, 60], ['high_writing', 60, 65, 75],
- ['word_self_study', 60, 20, 30],
- ];
- const currentRule = (categoryKey, durationMinutes, deliveryMode) => teachingStandards.rules
- .filter((rule) => rule.active && rule.categoryKey === categoryKey && rule.durationMinutes === durationMinutes && rule.deliveryMode === deliveryMode && rule.effectiveFrom <= today)
- .sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom))[0];
- for (const [categoryKey, durationMinutes, onlinePay, offlinePay] of expectedTeachingMatrix) {
- if (currentRule(categoryKey, durationMinutes, 'online')?.teacherPay !== onlinePay || currentRule(categoryKey, durationMinutes, 'offline')?.teacherPay !== offlinePay) throw new Error(`课程工资标准不符合新价目表:${categoryKey} ${durationMinutes}分钟`);
- }
- const databaseStages = new Set(teachingStandards.mappings.map((item) => item.stageName));
- if (!teachingStandards.mappings.every((item) => item.stageConfirmed && item.stageName) || !['小学', '初中', '高中'].every((name) => databaseStages.has(name)) || teachingStandards.mappings.some((item) => item.confirmed && !item.categoryKey)) throw new Error('历史课程没有原样返回数据库大类');
- const payrolls = (await ops({ operation: 'ops/payroll/list', page: 1, pageSize: 20 })).data;
- if (!Array.isArray(payrolls.items) || typeof payrolls.total !== 'number') throw new Error('工资批次未返回统一分页结构');
- const payrollOverview = (await ops({ operation: 'ops/payroll/overview', month: '2026-08', storeId: 0, search: '', refresh: true })).data;
- if (!payrollOverview.sourceStatus?.available || payrollOverview.sourceStatus.mode !== 'parse') throw new Error(`工资新数据库上课记录不可用:${payrollOverview.sourceStatus?.message || 'unknown'}`);
- if (!Array.isArray(payrollOverview.lines) || payrollOverview.lessonCount < 700 || !Array.isArray(payrollOverview.coachItems) || !payrollOverview.coachItems.length) throw new Error(`2026-08 工资收益投影数量异常:${payrollOverview.lessonCount}`);
- if (payrollOverview.lines.some((item) => !item.legacyGeneralId || !item.lessonAt || String(item.lessonAt).startsWith('{'))) throw new Error('工资明细缺少旧记录ID或规范上课日期');
- if (payrollOverview.lines.some((item) => item.classType === 4 && (item.amount !== 0 || item.durationHours !== 0 || item.exception))) throw new Error('课型4没有按只计课次、不计工资和工时处理');
- for (const sampleId of [850746, 850469]) {
- const sample = payrollOverview.lines.find((item) => item.legacyGeneralId === sampleId);
- if (!sample || !sample.coachName || !sample.studentName || !sample.lessonAt.startsWith('2026-08-25')) throw new Error(`工资样本 ${sampleId} 字段对账失败`);
- }
- const storeWithBusiness = stores.items.filter((item) => !item.readOnly).sort((a, b) => Number(b.memberCount || 0) + Number(b.coachCount || 0) - Number(a.memberCount || 0) - Number(a.coachCount || 0))[0];
- if (!storeWithBusiness) throw new Error('没有可用于经营详情回归的已落库门店');
- const storeDetail = (await ops({ operation: 'ops/stores/detail', objectId: storeWithBusiness.objectId, month: '2026-08', refresh: true })).data;
- if (!storeDetail.store || storeDetail.store.objectId !== storeWithBusiness.objectId || storeDetail.month !== '2026-08' || !Array.isArray(storeDetail.members) || !Array.isArray(storeDetail.coaches) || !Array.isArray(storeDetail.appointments) || !Array.isArray(storeDetail.courses)) throw new Error('门店经营详情结构无效');
- if (storeDetail.members.some((item) => Number(item.parentUserId) !== Number(storeDetail.store.userId) || item.groupId !== 1) || storeDetail.summary.memberCount !== storeDetail.members.length || storeDetail.summary.coachCount !== storeDetail.coaches.length) throw new Error('门店会员、陪练归属或摘要数量不一致');
- if (storeDetail.appointments.some((item) => !String(item.startsAt || '').startsWith('2026-08')) || storeDetail.commissionStatus?.available !== false || !String(storeDetail.commissionStatus?.message || '').includes('没有可验证')) throw new Error('门店月份过滤或佣金安全提示无效');
- if (storeDetail.summary.lessonCount !== payrollOverview.lines.filter((line) => Number(line.storeId) === Number(storeDetail.store.userId) && !line.exception).length) throw new Error('门店有效上课记录与工资来源口径不一致');
- const vocabularyTree = (await ops({ operation: 'ops/vocabulary/tree' })).data;
- if (!Array.isArray(vocabularyTree.items) || !vocabularyTree.items.some((item) => item.canContainWords) || !vocabularyTree.refreshedAt || vocabularyTree.totalWords < 100000) throw new Error('词库教材单元树结构无效或完整词库未补齐');
- const vocabulary = (await ops({ operation: 'ops/vocabulary/list', page: 1, pageSize: 5, search: '', filters: {} })).data;
- if (!Array.isArray(vocabulary.items) || vocabulary.total < 100000 || vocabulary.items.some((item) => !item.targetId || !item.word)) throw new Error(`词库业务投影数量或结构异常:${vocabulary.total}`);
- if (vocabularyTree.totalWords !== vocabulary.total) throw new Error(`词库目录总量与列表总量不一致:tree=${vocabularyTree.totalWords}, list=${vocabulary.total}`);
- const vocabularyParent = vocabularyTree.items.find((item) => item.childCount > 0 && item.wordCount > 0);
- if (!vocabularyParent || typeof vocabularyParent.directWordCount !== 'number') throw new Error('词库目录缺少父级汇总统计');
- const vocabularyScope = (await ops({ operation: 'ops/vocabulary/list', page: 1, pageSize: 5, search: '', filters: { nodeId: vocabularyParent.nodeId } })).data;
- if (vocabularyScope.total !== vocabularyParent.wordCount) throw new Error(`词库父级未包含子目录词条:node=${vocabularyParent.nodeId}, tree=${vocabularyParent.wordCount}, list=${vocabularyScope.total}`);
- const vocabularyAudio = (await ops({ operation: 'ops/vocabulary/list', page: 1, pageSize: 20, search: 'cherish', filters: {} })).data;
- const playableWord = vocabularyAudio.items.find((item) => /^https:\/\/a018\.2018\.z01\.com\/UploadFiles\//.test(String(item.audioUrl || '')));
- if (!playableWord) throw new Error('旧词库音频地址没有规范化为可访问的 HTTPS 完整地址');
- const audioResponse = await fetch(playableWord.audioUrl, { method: 'HEAD', redirect: 'follow' });
- if (!audioResponse.ok || !String(audioResponse.headers.get('content-type') || '').startsWith('audio/')) throw new Error(`旧词库音频不可播放:${audioResponse.status}`);
- const specialTraining = (await ops({ operation: 'ops/special-training/list', page: 1, pageSize: 50, search: '', filters: {}, refresh: false })).data;
- const specialIds = new Set(specialTraining.items?.map((item) => item.generalId));
- if (!Array.isArray(specialTraining.items) || specialTraining.total !== 19 || specialTraining.items.some((item) => !item.targetId || !item.title || !item.contentHtml) || ![212358, 197954, 332041, 332042, 332043].every((id) => specialIds.has(id))) throw new Error(`专项训练新数据库业务投影异常:${JSON.stringify({ total: specialTraining.total, sourceStatus: specialTraining.sourceStatus })}`);
- if (specialTraining.items.filter((item) => item.coverUrl).length < 18 || specialTraining.items.some((item) => /(?:src|href)=["']\/UploadFiles\//i.test(item.contentHtml))) throw new Error('专项训练封面回填或旧媒体地址规范化异常');
- const specialDetail = (await ops({ operation: 'ops/special-training/detail', targetId: specialTraining.items[0].targetId })).data;
- if (!specialDetail.title || !('contentHtml' in specialDetail) || !('synopsis' in specialDetail) || !('videoUrl' in specialDetail)) throw new Error('专项训练详情未正确联查 ContentArticle');
- for (const operation of ['ops/relations/list', 'ops/course-bindings/list', 'ops/appointments/list', 'ops/lessons/list', 'ops/learning-records/list', 'ops/finance/list', 'ops/money-logs/list']) {
- const page = (await ops({ operation, page: 1, pageSize: 5, search: '' })).data;
- if (!Array.isArray(page.items) || typeof page.total !== 'number') throw new Error(`${operation} 未返回统一分页结构`);
- }
- const learningReports = (await ops({ operation: 'ops/learning-reports/list', page: 1, pageSize: 20, search: '', filters: { month: '2026-09' }, refresh: true })).data;
- if (!Array.isArray(learningReports.items) || typeof learningReports.total !== 'number' || !learningReports.summary || !learningReports.sourceStatus) throw new Error('学习报表列表、摘要或同步状态结构无效');
- if (learningReports.sourceStatus.state === 'not_configured') throw new Error('学习记录持续同步桥尚未配置,无法保证报表包含最新数据');
- const historicalReport = (await ops({ operation: 'ops/learning-reports/detail', targetId: '855751' })).data;
- if (Number(historicalReport.generalId) !== 855751 || !learningReports.items.some((item) => String(item.studyDate || '') >= '2026-09-04')) throw new Error(`学习报表没有同步旧系统样本或最新记录:${JSON.stringify({ total: learningReports.total, sourceStatus: learningReports.sourceStatus, historicalId: historicalReport.generalId, ids: learningReports.items.map((item) => item.generalId) })}`);
- const studentReports = (await ops({ operation: 'ops/learning-reports/list', page: 1, pageSize: 20, search: '熊诗琦', filters: { month: '2026-09' } })).data;
- if (!studentReports.total || studentReports.items.some((item) => !String(item.studentName || '').includes('熊诗琦'))) throw new Error(`学习报表学员姓名筛选无效:${JSON.stringify({ total: studentReports.total, names: studentReports.items.map((item) => item.studentName) })}`);
- const coachReports = (await ops({ operation: 'ops/learning-reports/list', page: 1, pageSize: 20, search: '', filters: { month: '2026-09', coachSearch: '樊颖' } })).data;
- if (!coachReports.total || coachReports.items.some((item) => !String(item.coachName || '').includes('樊颖'))) throw new Error(`学习报表陪练筛选无效:${JSON.stringify({ total: coachReports.total, names: coachReports.items.map((item) => item.coachName) })}`);
- const courseReports = (await ops({ operation: 'ops/learning-reports/list', page: 1, pageSize: 20, search: '', filters: { month: '2026-09', courseSearch: '人教版(PEP)' } })).data;
- if (!courseReports.total || courseReports.items.some((item) => !String(item.courseName || '').includes('人教版(PEP)'))) throw new Error(`学习报表课程筛选无效:${JSON.stringify({ total: courseReports.total, names: courseReports.items.map((item) => item.courseName) })}`);
- const exportableReport = learningReports.items.find((item) => item.canExport);
- if (exportableReport) {
- const reportDetail = (await ops({ operation: 'ops/learning-reports/detail', targetId: exportableReport.targetId })).data;
- if (!Array.isArray(reportDetail.words) || reportDetail.words.length !== reportDetail.learnedCount || !Array.isArray(reportDetail.reviewSchedule)) throw new Error('学习报表详情单词或复习计划结构无效');
- if (reportDetail.words.some((word) => !word.word || !word.resultLabel)) throw new Error('学习报表单词明细缺少单词或学习结果');
- const exportSnapshot = (await ops({ operation: 'ops/learning-reports/export', targetId: exportableReport.targetId })).data;
- if (exportSnapshot.generalId !== reportDetail.generalId || exportSnapshot.words?.length !== reportDetail.words.length || !exportSnapshot.generatedAt) throw new Error('学习报表 PDF 导出快照与详情不一致');
- }
- const denied = await call('xiaoshu/admin/gateway', token, { operation: 'catalog' }, 403);
- if (denied.success !== false) throw new Error('普通运营管理员仍可访问技术数据目录');
- console.log(`Operations smoke passed: users=${users.total}, newestMember=${users.items[0]?.displayName || 'none'}@${users.items[0]?.registeredAt || 'none'}, coaches=${coaches.total}, activeCoaches=${activeCoaches.total}, monthActiveCoaches=${coaches.summary.monthActive}, dashboardCoaches=${dashboardCoachMetric.value}, liveCoachAppointments=${coaches.liveSourceCount}, appointments=${schedule.totalAppointments}, latest=${latestSchedule.appointments.length}, vocabulary=${vocabulary.total}, specialTraining=${specialTraining.total}, identityConflicts=${identityConflicts.total}, conflictNames=${identityConflicts.items.map((item) => item.displayName).join('|') || 'none'}, metrics=${dashboard.metrics.length}, learning=${JSON.stringify(learningCounts)}.`);
- } finally {
- if (userId) {
- const sessions = await request(`${PARSE_URL}/classes/_Session?where=${encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }))}&limit=1000`, {}, true).catch(() => ({ results: [] }));
- for (const session of sessions.results || []) await request(`${PARSE_URL}/classes/_Session/${session.objectId}`, { method: 'DELETE' }, true).catch(() => undefined);
- await request(`${PARSE_URL}/users/${userId}`, { method: 'DELETE' }, true).catch(() => undefined);
- }
- }
|