ensure-app-performance-indexes.mjs 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  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 = PARSE_URL.replace(/\/parse$/, '/api/functions');
  7. if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
  8. const headers = { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Master-Key': MASTER_KEY, 'Content-Type': 'application/json' };
  9. async function parse(path, init = {}) {
  10. const response = await fetch(`${PARSE_URL}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } });
  11. const payload = await response.json().catch(() => ({}));
  12. if (!response.ok || payload.error) throw new Error(typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error || { status: response.status }));
  13. return payload;
  14. }
  15. const company = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0];
  16. if (!company?.objectId) throw new Error('生产 Parse 未找到 Company');
  17. const suffix = `${Date.now()}_${randomBytes(4).toString('hex')}`;
  18. const username = `index_maintainer_${suffix}`;
  19. const password = `${randomBytes(24).toString('base64url')}Aa9!`;
  20. const path = `xiaoshu/system/ensure-app-indexes-${suffix}`;
  21. let userId = '';
  22. let functionId = '';
  23. const statements = [
  24. 'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_item" ON "CommonModel" ("company","modelId","itemId")',
  25. 'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_general" ON "CommonModel" ("company","modelId","generalId")',
  26. 'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_node" ON "CommonModel" ("company","modelId","nodeId")',
  27. 'CREATE INDEX IF NOT EXISTS "idx_cm_company_model_node_order" ON "CommonModel" ("company","modelId","nodeId","orderId","generalId")',
  28. 'CREATE INDEX IF NOT EXISTS "idx_daily_company_user_date" ON "DailyStudyRecord" ("company","userId","dqrq")',
  29. 'CREATE INDEX IF NOT EXISTS "idx_daily_company_user_updated" ON "DailyStudyRecord" ("company","userId","updatedAt" DESC)',
  30. 'CREATE INDEX IF NOT EXISTS "idx_daily_company_date_user" ON "DailyStudyRecord" ("company","dqrq","userId")',
  31. 'CREATE INDEX IF NOT EXISTS "idx_daily_company_coach_date" ON "DailyStudyRecord" ("company","pl","dqrq")',
  32. 'CREATE INDEX IF NOT EXISTS "idx_daily_company_appointment" ON "DailyStudyRecord" ("company","dsid")',
  33. 'CREATE INDEX IF NOT EXISTS "idx_daily_company_id" ON "DailyStudyRecord" ("company","id")',
  34. 'CREATE INDEX IF NOT EXISTS "idx_appt_company_student_time" ON "CourseAppointment" ("company","szyh","yysj")',
  35. 'CREATE INDEX IF NOT EXISTS "idx_appt_company_student_updated" ON "CourseAppointment" ("company","szyh","updatedAt" DESC)',
  36. 'CREATE INDEX IF NOT EXISTS "idx_appt_company_coach_updated" ON "CourseAppointment" ("company","pl","updatedAt" DESC)',
  37. 'CREATE INDEX IF NOT EXISTS "idx_appt_company_assistant_updated" ON "CourseAppointment" ("company","fxpl","updatedAt" DESC)',
  38. 'CREATE INDEX IF NOT EXISTS "idx_appt_company_coach_time" ON "CourseAppointment" ("company","pl","yysj")',
  39. 'CREATE INDEX IF NOT EXISTS "idx_appt_company_student_effective_date" ON "CourseAppointment" ("company","szyh",(REPLACE(LEFT(COALESCE(NULLIF("bxrq",\'\'),"yysj"),10),\'-\',\'\')))',
  40. 'CREATE INDEX IF NOT EXISTS "idx_appt_company_coach_effective_date" ON "CourseAppointment" ("company","pl",(REPLACE(LEFT(COALESCE(NULLIF("bxrq",\'\'),"yysj"),10),\'-\',\'\')))',
  41. 'CREATE INDEX IF NOT EXISTS "idx_appt_company_id" ON "CourseAppointment" ("company","id")',
  42. 'CREATE INDEX IF NOT EXISTS "idx_binding_company_user_id" ON "CourseBinding" ("company","yhid","id")',
  43. 'CREATE INDEX IF NOT EXISTS "idx_binding_company_user_updated" ON "CourseBinding" ("company","yhid","updatedAt" DESC)',
  44. 'CREATE INDEX IF NOT EXISTS "idx_memory_company_user_time" ON "MemoryPracticeRecord" ("company","yhid","kywsj")',
  45. 'CREATE INDEX IF NOT EXISTS "idx_memory_company_user_updated" ON "MemoryPracticeRecord" ("company","yhid","updatedAt" DESC)',
  46. 'CREATE INDEX IF NOT EXISTS "idx_memory_company_coach_updated" ON "MemoryPracticeRecord" ("company","plid","updatedAt" DESC)',
  47. 'CREATE INDEX IF NOT EXISTS "idx_memory_company_coach_status" ON "MemoryPracticeRecord" ("company","plid","fxzt")',
  48. 'CREATE INDEX IF NOT EXISTS "idx_memory_company_id" ON "MemoryPracticeRecord" ("company","id")',
  49. 'CREATE INDEX IF NOT EXISTS "idx_practice_company_user_word_updated" ON "PracticeRecord" ("company",(CAST("yhid" AS text)),(CAST("scid" AS text)),"updatedAt" DESC)',
  50. 'CREATE INDEX IF NOT EXISTS "idx_lesson_company_coach_time" ON "LessonRecord" ("company","jsmz","lessonAt")',
  51. 'CREATE INDEX IF NOT EXISTS "idx_lesson_company_student_updated" ON "LessonRecord" ("company","xymz","updatedAt" DESC)',
  52. 'CREATE INDEX IF NOT EXISTS "idx_lesson_company_coach_updated" ON "LessonRecord" ("company","jsmz","updatedAt" DESC)',
  53. 'CREATE INDEX IF NOT EXISTS "idx_reservation_company_student_updated" ON "CourseCreditReservation" ("company","studentId","updatedAt" DESC)',
  54. 'CREATE INDEX IF NOT EXISTS "idx_reading_item_user_updated" ON "SurveyItem" ("user","type","updatedAt" DESC)',
  55. 'CREATE INDEX IF NOT EXISTS "idx_reading_log_user_updated" ON "SurveyLog" ("user","updatedAt" DESC)',
  56. 'CREATE INDEX IF NOT EXISTS "idx_node_company_node" ON "Node" ("company","nodeId")',
  57. 'CREATE INDEX IF NOT EXISTS "idx_vocab_company_id" ON "VocabularyWord" ("company","id")',
  58. ];
  59. const indexNames = statements.map((statement) => statement.match(/"([^"]+)"/)?.[1] || '');
  60. const code = String.raw`
  61. async function handler(request, response) {
  62. try {
  63. const current = request.user || (typeof user !== 'undefined' ? user : null);
  64. if (!current) return response.status(401).json({ success:false, message:'需要超级管理员会话' });
  65. await current.fetch({ useMasterKey:true });
  66. const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
  67. if (current.get('adminRoleKey') !== 'super-admin' && !roles.includes('super-admin')) return response.status(403).json({ success:false, message:'仅超级管理员可执行' });
  68. const statements = ${JSON.stringify(statements)};
  69. const applied = [], skipped = [];
  70. for (const statement of statements) {
  71. try {
  72. await Psql.none(statement);
  73. applied.push(statement.match(/"([^"]+)"/)?.[1] || statement);
  74. } catch (error) {
  75. skipped.push({ index: statement.match(/"([^"]+)"/)?.[1] || statement, reason: String(error.message || error) });
  76. }
  77. }
  78. const indexes = await Psql.query('SELECT indexname FROM pg_indexes WHERE schemaname=current_schema() AND indexname=ANY($1::text[]) ORDER BY indexname', [${JSON.stringify(indexNames)}]);
  79. response.json({ success:true, data:{ applied, skipped, indexes:indexes.map((row) => row.indexname) } });
  80. } catch (error) {
  81. response.status(Number(error.status)||500).json({ success:false, message:String(error.message||error) });
  82. }
  83. }`;
  84. try {
  85. const createdUser = await parse('/users', {
  86. method: 'POST',
  87. body: JSON.stringify({ username, password, isAdmin: true, role: 'admin', roles: ['admin', 'super-admin'], adminRoleKey: 'super-admin', company: { __type: 'Pointer', className: 'Company', objectId: company.objectId }, realName: '应用查询索引临时管理员', testCreatedBy: 'ensure-app-performance-indexes' }),
  88. });
  89. userId = createdUser.objectId;
  90. let token = createdUser.sessionToken || '';
  91. if (!token) token = (await parse('/login', { method: 'POST', body: JSON.stringify({ username, password }) })).sessionToken;
  92. if (!token) throw new Error('临时超级管理员没有会话');
  93. const createdFunction = await parse('/classes/Function', { method: 'POST', body: JSON.stringify({ name: path, desc: '一次性补齐用户端高频查询索引', type: 'standalone', path, code, params: [], paramList: [], respType: 'json', respJson: { success: true }, enabled: true }) });
  94. functionId = createdFunction.objectId;
  95. 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: {} }) });
  96. const payload = await response.json().catch(() => ({}));
  97. if (!response.ok || payload.success !== true) throw new Error(payload.message || payload.error || `索引维护失败:${response.status}`);
  98. console.log(JSON.stringify({ ensured: true, applied: payload.data?.applied || [], skipped: payload.data?.skipped || [], indexes: payload.data?.indexes || [] }, null, 2));
  99. } finally {
  100. if (functionId) await parse(`/classes/Function/${functionId}`, { method: 'DELETE' }).catch(() => undefined);
  101. if (userId) {
  102. const where = encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }));
  103. const sessions = await parse(`/classes/_Session?where=${where}&limit=1000&keys=objectId`).catch(() => ({ results: [] }));
  104. for (const session of sessions.results || []) await parse(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined);
  105. await parse(`/users/${userId}`, { method: 'DELETE' }).catch(() => undefined);
  106. }
  107. }