repair-core-physical-schema.mjs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  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) throw new Error(typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error || { status: response.status }));
  17. return payload;
  18. }
  19. const company = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0];
  20. if (!company?.objectId) throw new Error('生产 Parse 未找到 Company');
  21. const suffix = `${Date.now()}_${randomBytes(4).toString('hex')}`;
  22. const username = `schema_repair_${suffix}`;
  23. const password = `${randomBytes(24).toString('base64url')}Aa9!`;
  24. const path = `xiaoshu/system/repair-core-schema-${suffix}`;
  25. let userId = '';
  26. let functionId = '';
  27. const tables = ['PracticeRecord','CourseAppointment','DailyStudyRecord','CourseBinding','LessonRecord','MemoryPracticeRecord','AssessmentProfile'];
  28. const probeFields = {
  29. PracticeRecord: ['id', 'sourceKey'],
  30. CourseAppointment: ['id', 'sourceKey'],
  31. DailyStudyRecord: ['id', 'sourceKey'],
  32. CourseBinding: ['id', 'sourceKey'],
  33. LessonRecord: ['id', 'sourceKey', 'legacyGeneralId', 'lessonAt', 'sourceCreatedAt', 'studentName', 'coachName', 'courseName', 'storeId'],
  34. MemoryPracticeRecord: ['id', 'sourceKey'],
  35. AssessmentProfile: ['id', 'sourceKey'],
  36. };
  37. const code = String.raw`
  38. async function handler(request, response) {
  39. try {
  40. const current = request.user || (typeof user !== 'undefined' ? user : null);
  41. if (!current) return response.status(401).json({ success:false, message:'需要超级管理员会话' });
  42. await current.fetch({ useMasterKey:true });
  43. const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
  44. if (current.get('adminRoleKey') !== 'super-admin' && !roles.includes('super-admin')) return response.status(403).json({ success:false, message:'仅超级管理员可执行' });
  45. const statements = [
  46. ${tables.map((table) => `'ALTER TABLE "${table}" ADD COLUMN IF NOT EXISTS "_rperm" TEXT[]'`).join(',\n ')},
  47. ${tables.map((table) => `'ALTER TABLE "${table}" ADD COLUMN IF NOT EXISTS "_wperm" TEXT[]'`).join(',\n ')},
  48. 'ALTER TABLE "PracticeRecord" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
  49. 'ALTER TABLE "PracticeRecord" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT',
  50. 'ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
  51. 'ALTER TABLE "CourseAppointment" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT',
  52. 'ALTER TABLE "DailyStudyRecord" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
  53. 'ALTER TABLE "DailyStudyRecord" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT',
  54. 'ALTER TABLE "CourseBinding" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
  55. 'ALTER TABLE "CourseBinding" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT',
  56. 'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
  57. 'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT',
  58. 'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "legacyGeneralId" DOUBLE PRECISION',
  59. 'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "lessonAt" TIMESTAMPTZ',
  60. 'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "sourceCreatedAt" TIMESTAMPTZ',
  61. 'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "studentName" TEXT',
  62. 'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "coachName" TEXT',
  63. 'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "courseName" TEXT',
  64. 'ALTER TABLE "LessonRecord" ADD COLUMN IF NOT EXISTS "storeId" DOUBLE PRECISION',
  65. 'ALTER TABLE "MemoryPracticeRecord" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
  66. 'ALTER TABLE "MemoryPracticeRecord" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT',
  67. 'ALTER TABLE "AssessmentProfile" ADD COLUMN IF NOT EXISTS "id" DOUBLE PRECISION',
  68. 'ALTER TABLE "AssessmentProfile" ADD COLUMN IF NOT EXISTS "sourceKey" TEXT'
  69. ];
  70. for (const statement of statements) await Psql.none(statement);
  71. const rows = await Psql.query(
  72. 'SELECT table_name AS "tableName", column_name AS "columnName", data_type AS "dataType" FROM information_schema.columns WHERE table_schema=current_schema() AND table_name = ANY($1::text[]) AND column_name = ANY($2::text[]) ORDER BY table_name,column_name',
  73. [${JSON.stringify(tables)}, ['_rperm','_wperm','id','sourceKey','legacyGeneralId','lessonAt','sourceCreatedAt','studentName','coachName','courseName','storeId']]
  74. );
  75. response.json({ success:true, data:{ applied:statements.length, columns:rows } });
  76. } catch (error) {
  77. response.status(Number(error.status)||500).json({ success:false, message:String(error.message||error) });
  78. }
  79. }`;
  80. try {
  81. const createdUser = await parse('/users', {
  82. method: 'POST',
  83. body: JSON.stringify({
  84. username, password, isAdmin: true, role: 'admin', roles: ['admin','super-admin'], adminRoleKey: 'super-admin',
  85. company: { __type: 'Pointer', className: 'Company', objectId: company.objectId },
  86. realName: '核心迁移物理Schema修复临时管理员', testCreatedBy: 'repair-core-physical-schema',
  87. }),
  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', {
  94. method: 'POST',
  95. body: JSON.stringify({
  96. name: path, desc: '一次性修复小树核心业务表物理列', type: 'standalone', path, code,
  97. params: [], paramList: [], respType: 'json', respJson: { success: true }, enabled: true,
  98. }),
  99. });
  100. functionId = createdFunction.objectId;
  101. const response = await fetch(`${FUNCTION_URL}/${path}`, {
  102. method: 'POST',
  103. headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' },
  104. body: JSON.stringify({ token, params: {} }),
  105. });
  106. const payload = await response.json().catch(() => ({}));
  107. if (!response.ok || payload.success !== true) throw new Error(payload.message || payload.error || `物理 Schema 修复失败:${response.status}`);
  108. const verified = [];
  109. for (const [className, fields] of Object.entries(probeFields)) {
  110. const query = new URLSearchParams({ limit: '1', keys: ['objectId', ...fields].join(',') });
  111. const probe = await fetch(`${PARSE_URL}/classes/${className}?${query}`, { headers });
  112. const probePayload = await probe.json().catch(() => ({}));
  113. if (!probe.ok || probePayload.error) throw new Error(`${className} 物理列修复后仍不可读取:${JSON.stringify(probePayload)}`);
  114. verified.push(className);
  115. }
  116. console.log(JSON.stringify({ repaired: true, applied: payload.data?.applied || 0, columns: payload.data?.columns || [], verified }, null, 2));
  117. } finally {
  118. if (functionId) await parse(`/classes/Function/${functionId}`, { method: 'DELETE' }).catch(() => undefined);
  119. if (userId) {
  120. const where = encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } }));
  121. const sessions = await parse(`/classes/_Session?where=${where}&limit=1000&keys=objectId`).catch(() => ({ results: [] }));
  122. for (const session of sessions.results || []) await parse(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined);
  123. await parse(`/users/${userId}`, { method: 'DELETE' }).catch(() => undefined);
  124. }
  125. }