smoke-operations-functions.mjs 8.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. #!/usr/bin/env node
  2. import { randomBytes } from 'node:crypto';
  3. const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
  4. const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
  5. const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
  6. const FUNCTION_URL = (process.env.XIAOSHU_FUNCTION_URL || 'https://server.xiaoshu.pro/api/functions').replace(/\/$/, '');
  7. if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
  8. async function request(url, init = {}, master = false) {
  9. 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 || {}) } });
  10. const payload = await response.json().catch(() => ({}));
  11. if (!response.ok) throw new Error(`${response.status}: ${payload.message || payload.error || JSON.stringify(payload)}`);
  12. return payload;
  13. }
  14. async function call(path, token, params, expectedStatus = 200) {
  15. 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 }) });
  16. const payload = await response.json().catch(() => ({}));
  17. if (response.status !== expectedStatus) throw new Error(`${path} 期望 ${expectedStatus},实际 ${response.status}: ${payload.message || payload.error || ''}`);
  18. if (expectedStatus === 200 && payload.success !== true) throw new Error(payload.message || `${path} 返回失败`);
  19. return payload;
  20. }
  21. let userId = '';
  22. try {
  23. const companies = await request(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
  24. const companyId = companies.results?.[0]?.objectId;
  25. if (!companyId) throw new Error('没有可用于运营网关测试的帐套');
  26. const company = { __type: 'Pointer', className: 'Company', objectId: companyId };
  27. const username = `codex_ops_smoke_${Date.now()}`;
  28. const password = randomBytes(24).toString('base64url');
  29. const created = await request(`${PARSE_URL}/users`, { method: 'POST', body: JSON.stringify({ username, password, isAdmin: true, roles: ['admin', 'ops-manager'], company }) }, true);
  30. userId = created.objectId;
  31. const login = await request(`${PARSE_URL}/login`, { method: 'POST', body: JSON.stringify({ username, password }) });
  32. const token = login.sessionToken;
  33. if (!token) throw new Error('临时运营管理员未返回会话');
  34. const ops = (params, expectedStatus = 200) => call('xiaoshu/ops/gateway-v3', token, params, expectedStatus);
  35. const dashboard = (await ops({ operation: 'ops/dashboard/summary' })).data;
  36. if (dashboard.identity?.operationsRole !== 'ops-manager' || !Array.isArray(dashboard.metrics) || dashboard.metrics.length < 4) throw new Error('运营工作台返回结构无效');
  37. const users = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 20, search: '' })).data;
  38. if (!Array.isArray(users.items) || !users.items.length || users.items.some((item) => 'legacyUserData' in item || 'legacyPasswordHash' in item || 'ACL' in item)) throw new Error('用户业务投影为空或泄漏技术字段');
  39. if (users.items.some((item) => item.username === username) || users.items.some((item) => !item.learningStatus || !item.learningStatusLabel) || !users.refreshedAt) throw new Error('管理员过滤、学习状态或刷新时间无效');
  40. const learningCounts = {};
  41. for (const learningStatus of ['trial', 'studying', 'completed', 'not_started', 'not_applicable']) {
  42. const result = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 1, search: '', filters: { learningStatus } })).data;
  43. learningCounts[learningStatus] = result.total;
  44. }
  45. if (Object.values(learningCounts).reduce((sum, value) => sum + Number(value || 0), 0) !== users.total) throw new Error(`学习状态分类数量与会员总数不一致:${JSON.stringify(learningCounts)}`);
  46. const named = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 20, search: '万宇昊' })).data;
  47. if (!named.items?.some((item) => item.displayName === '万宇昊')) throw new Error('用户显示名称兜底未找到“万宇昊”');
  48. const legacyAdmin = (await ops({ operation: 'ops/users/list', page: 1, pageSize: 20, search: 'admin' })).data;
  49. if (legacyAdmin.items?.some((item) => String(item.username || '').toLowerCase() === 'admin')) throw new Error('Manager 表中的历史管理员仍混入会员列表');
  50. const coaches = (await ops({ operation: 'ops/coaches/list', page: 1, pageSize: 20, search: '' })).data;
  51. if (!Array.isArray(coaches.items) || coaches.items.some((item) => !item.userId || !item.displayName)) throw new Error('陪练老师业务投影无效');
  52. const today = new Date().toISOString().slice(0, 10);
  53. const schedule = (await ops({ operation: 'ops/schedule/calendar', dateFrom: today, dateTo: today, filters: {} })).data;
  54. if (!Array.isArray(schedule.appointments) || !Array.isArray(schedule.coaches) || !Array.isArray(schedule.members) || !Array.isArray(schedule.courses) || !schedule.refreshedAt) throw new Error('排课中心日历或业务选项结构无效');
  55. 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 })}`);
  56. const latestSchedule = (await ops({ operation: 'ops/schedule/calendar', dateFrom: schedule.dataDateTo, dateTo: schedule.dataDateTo, filters: {} })).data;
  57. if (!latestSchedule.appointments?.length || latestSchedule.totalAppointments !== schedule.totalAppointments) throw new Error('最近历史排课未正确投影到排课中心');
  58. if (!coaches.items?.some((item) => item.totalAppointments > 0 && item.lastAppointmentAt)) throw new Error('陪练列表缺少历史排课汇总');
  59. const rates = (await ops({ operation: 'ops/pay-rates/list', page: 1, pageSize: 100 })).data;
  60. for (const expected of [{ type: 1, amount: 20, hours: .5 }, { type: 2, amount: 40, hours: 1 }, { type: 3, amount: 40, hours: 1 }]) {
  61. if (!rates.items?.some((item) => item.classType === expected.type && item.amount === expected.amount && item.durationHours === expected.hours)) throw new Error(`原系统课酬规则缺失:课型 ${expected.type}`);
  62. }
  63. if (rates.items?.some((item) => item.classType === 4 && item.isDefault)) throw new Error('课型4不应存在默认工资规则');
  64. const payrolls = (await ops({ operation: 'ops/payroll/list', page: 1, pageSize: 20 })).data;
  65. if (!Array.isArray(payrolls.items) || typeof payrolls.total !== 'number') throw new Error('工资批次未返回统一分页结构');
  66. 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']) {
  67. const page = (await ops({ operation, page: 1, pageSize: 5, search: '' })).data;
  68. if (!Array.isArray(page.items) || typeof page.total !== 'number') throw new Error(`${operation} 未返回统一分页结构`);
  69. }
  70. const denied = await call('xiaoshu/admin/gateway', token, { operation: 'catalog' }, 403);
  71. if (denied.success !== false) throw new Error('普通运营管理员仍可访问技术数据目录');
  72. console.log(`Operations smoke passed: users=${users.total}, coaches=${coaches.total}, appointments=${schedule.totalAppointments}, latest=${latestSchedule.appointments.length}, metrics=${dashboard.metrics.length}, learning=${JSON.stringify(learningCounts)}.`);
  73. } finally {
  74. if (userId) {
  75. const sessions = await request(`${PARSE_URL}/classes/_Session?where=${encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }))}&limit=1000`, {}, true).catch(() => ({ results: [] }));
  76. for (const session of sessions.results || []) await request(`${PARSE_URL}/classes/_Session/${session.objectId}`, { method: 'DELETE' }, true).catch(() => undefined);
  77. await request(`${PARSE_URL}/users/${userId}`, { method: 'DELETE' }, true).catch(() => undefined);
  78. }
  79. }