#!/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 response = await fetch(`${FUNCTION_URL}/${path}`, { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, params }) }); 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} 返回失败`); 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.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) => item.displayName === '万宇昊')) 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 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; 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.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 !== schedule.liveSourceAvailable || !coaches.liveSourceCount) 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 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 !== 'live') 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 vocabularyTree = (await ops({ operation: 'ops/vocabulary/tree' })).data; if (!Array.isArray(vocabularyTree.items) || !vocabularyTree.items.some((item) => item.canContainWords) || !vocabularyTree.refreshedAt || vocabularyTree.totalWords < 80000) 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 < 80000 || 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 specialTraining = (await ops({ operation: 'ops/special-training/list', page: 1, pageSize: 20, search: '', filters: {} })).data; if (!Array.isArray(specialTraining.items) || specialTraining.total < 6 || specialTraining.items.some((item) => !item.targetId || !item.title)) throw new Error(`专项训练业务投影数量或结构异常:${specialTraining.total}`); 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 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}, 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); } }