diagnose-parse-psql-schema.mjs 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  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 = {
  9. 'X-Parse-Application-Id': APP_ID,
  10. 'X-Parse-Master-Key': MASTER_KEY,
  11. 'Content-Type': 'application/json',
  12. };
  13. async function parse(path, init = {}) {
  14. const response = await fetch(`${PARSE_URL}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } });
  15. const payload = await response.json().catch(() => ({}));
  16. if (!response.ok || payload.error) {
  17. throw new Error(typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error || { status: response.status }));
  18. }
  19. return payload;
  20. }
  21. const tables = ['CommonModel', 'PracticeRecord', 'CourseAppointment', 'DailyStudyRecord', 'CourseBinding', 'LessonRecord', 'MemoryPracticeRecord', 'AssessmentProfile'];
  22. const company = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0];
  23. if (!company?.objectId) throw new Error('生产 Parse 未找到 Company');
  24. const rest = {};
  25. for (const table of tables) {
  26. try {
  27. const payload = await parse(`/classes/${table}?limit=0&count=1`);
  28. rest[table] = { count: Number(payload.count || 0), sampleObjectId: '' };
  29. } catch (error) {
  30. rest[table] = { count: null, sampleObjectId: '', error: String(error.message || error) };
  31. }
  32. }
  33. const suffix = `${Date.now()}_${randomBytes(4).toString('hex')}`;
  34. const username = `schema_diag_${suffix}`;
  35. const password = `${randomBytes(24).toString('base64url')}Aa9!`;
  36. const path = `xiaoshu/system/diagnose-parse-schema-${suffix}`;
  37. let userId = '';
  38. let functionId = '';
  39. const code = String.raw`
  40. async function handler(request, response) {
  41. try {
  42. const current = request.user || (typeof user !== 'undefined' ? user : null);
  43. if (!current) return response.status(401).json({ success:false, message:'需要超级管理员会话' });
  44. await current.fetch({ useMasterKey:true });
  45. const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
  46. if (current.get('adminRoleKey') !== 'super-admin' && !roles.includes('super-admin')) {
  47. return response.status(403).json({ success:false, message:'仅超级管理员可执行' });
  48. }
  49. const environment = await Psql.one(
  50. 'SELECT current_database() AS database, current_schema() AS schema, current_user AS "currentUser", session_user AS "sessionUser", current_setting(\'search_path\') AS "searchPath"'
  51. );
  52. const relations = await Psql.query(
  53. 'SELECT n.nspname AS schema,c.relname AS table,c.relkind AS kind,c.oid::text AS oid FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.relname=ANY($1::text[]) ORDER BY c.relname,n.nspname',
  54. [${JSON.stringify(tables)}]
  55. );
  56. const columns = await Psql.query(
  57. 'SELECT table_schema AS schema,table_name AS table,column_name AS column,data_type AS type FROM information_schema.columns WHERE table_name=ANY($1::text[]) AND column_name=ANY($2::text[]) ORDER BY table_name,table_schema,column_name',
  58. [${JSON.stringify(tables)}, ['objectId','createdAt','updatedAt','_rperm','_wperm','ACL','id','sourceKey','legacyGeneralId','lessonAt','sourceCreatedAt','studentName','coachName','courseName','storeId']]
  59. );
  60. const counts = [];
  61. for (const table of ${JSON.stringify(tables)}) {
  62. const safe = String(table).replace(/"/g, '');
  63. const row = await Psql.one('SELECT COUNT(*)::int AS count,COALESCE(MIN("objectId"),\'\') AS "sampleObjectId" FROM "' + safe + '"');
  64. counts.push({ table:safe, count:Number(row.count || 0), sampleObjectId:String(row.sampleObjectId || '') });
  65. }
  66. response.json({ success:true, data:{ environment, relations, columns, counts } });
  67. } catch (error) {
  68. response.status(Number(error.status)||500).json({ success:false, message:String(error.message||error) });
  69. }
  70. }`;
  71. try {
  72. const createdUser = await parse('/users', {
  73. method: 'POST',
  74. body: JSON.stringify({
  75. username, password, isAdmin: true, role: 'admin', roles: ['admin', 'super-admin'], adminRoleKey: 'super-admin',
  76. company: { __type: 'Pointer', className: 'Company', objectId: company.objectId },
  77. realName: 'Parse 物理 Schema 只读诊断临时管理员', testCreatedBy: 'diagnose-parse-psql-schema',
  78. }),
  79. });
  80. userId = createdUser.objectId;
  81. let token = createdUser.sessionToken || '';
  82. if (!token) token = (await parse('/login', { method: 'POST', body: JSON.stringify({ username, password }) })).sessionToken;
  83. if (!token) throw new Error('临时超级管理员没有会话');
  84. const createdFunction = await parse('/classes/Function', {
  85. method: 'POST',
  86. body: JSON.stringify({
  87. name: path, desc: '一次性只读诊断 Parse 与 Psql 物理 Schema', type: 'standalone', path, code,
  88. params: [], paramList: [], respType: 'json', respJson: { success: true }, enabled: true,
  89. }),
  90. });
  91. functionId = createdFunction.objectId;
  92. const response = await fetch(`${FUNCTION_URL}/${path}`, {
  93. method: 'POST',
  94. headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' },
  95. body: JSON.stringify({ token, params: {} }),
  96. });
  97. const payload = await response.json().catch(() => ({}));
  98. if (!response.ok || payload.success !== true) throw new Error(payload.message || payload.error || `物理 Schema 诊断失败:${response.status}`);
  99. console.log(JSON.stringify({ rest, psql: payload.data }, null, 2));
  100. } finally {
  101. if (functionId) await parse(`/classes/Function/${functionId}`, { method: 'DELETE' }).catch(() => undefined);
  102. if (userId) {
  103. const where = encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }));
  104. const sessions = await parse(`/classes/_Session?where=${where}&limit=1000&keys=objectId`).catch(() => ({ results: [] }));
  105. for (const session of sessions.results || []) await parse(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined);
  106. await parse(`/users/${userId}`, { method: 'DELETE' }).catch(() => undefined);
  107. }
  108. }