#!/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 = PARSE_URL.replace(/\/parse$/, '/api/functions'); if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY'); const headers = { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Master-Key': MASTER_KEY, 'Content-Type': 'application/json', }; async function parse(path, init = {}) { const response = await fetch(`${PARSE_URL}${path}`, { ...init, headers: { ...headers, ...(init.headers || {}) } }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.error) { throw new Error(typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error || { status: response.status })); } return payload; } const tables = ['CommonModel', 'PracticeRecord', 'CourseAppointment', 'DailyStudyRecord', 'CourseBinding', 'LessonRecord', 'MemoryPracticeRecord', 'AssessmentProfile']; const company = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0]; if (!company?.objectId) throw new Error('生产 Parse 未找到 Company'); const rest = {}; for (const table of tables) { try { const payload = await parse(`/classes/${table}?limit=0&count=1`); rest[table] = { count: Number(payload.count || 0), sampleObjectId: '' }; } catch (error) { rest[table] = { count: null, sampleObjectId: '', error: String(error.message || error) }; } } const suffix = `${Date.now()}_${randomBytes(4).toString('hex')}`; const username = `schema_diag_${suffix}`; const password = `${randomBytes(24).toString('base64url')}Aa9!`; const path = `xiaoshu/system/diagnose-parse-schema-${suffix}`; let userId = ''; let functionId = ''; const code = String.raw` async function handler(request, response) { try { const current = request.user || (typeof user !== 'undefined' ? user : null); if (!current) return response.status(401).json({ success:false, message:'需要超级管理员会话' }); await current.fetch({ useMasterKey:true }); const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : []; if (current.get('adminRoleKey') !== 'super-admin' && !roles.includes('super-admin')) { return response.status(403).json({ success:false, message:'仅超级管理员可执行' }); } const environment = await Psql.one( 'SELECT current_database() AS database, current_schema() AS schema, current_user AS "currentUser", session_user AS "sessionUser", current_setting(\'search_path\') AS "searchPath"' ); const relations = await Psql.query( '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', [${JSON.stringify(tables)}] ); const columns = await Psql.query( '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', [${JSON.stringify(tables)}, ['objectId','createdAt','updatedAt','_rperm','_wperm','ACL','id','sourceKey','legacyGeneralId','lessonAt','sourceCreatedAt','studentName','coachName','courseName','storeId']] ); const counts = []; for (const table of ${JSON.stringify(tables)}) { const safe = String(table).replace(/"/g, ''); const row = await Psql.one('SELECT COUNT(*)::int AS count,COALESCE(MIN("objectId"),\'\') AS "sampleObjectId" FROM "' + safe + '"'); counts.push({ table:safe, count:Number(row.count || 0), sampleObjectId:String(row.sampleObjectId || '') }); } response.json({ success:true, data:{ environment, relations, columns, counts } }); } catch (error) { response.status(Number(error.status)||500).json({ success:false, message:String(error.message||error) }); } }`; try { const createdUser = await parse('/users', { method: 'POST', 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: 'Parse 物理 Schema 只读诊断临时管理员', testCreatedBy: 'diagnose-parse-psql-schema', }), }); userId = createdUser.objectId; let token = createdUser.sessionToken || ''; if (!token) token = (await parse('/login', { method: 'POST', body: JSON.stringify({ username, password }) })).sessionToken; if (!token) throw new Error('临时超级管理员没有会话'); const createdFunction = await parse('/classes/Function', { method: 'POST', body: JSON.stringify({ name: path, desc: '一次性只读诊断 Parse 与 Psql 物理 Schema', type: 'standalone', path, code, params: [], paramList: [], respType: 'json', respJson: { success: true }, enabled: true, }), }); functionId = createdFunction.objectId; 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.ok || payload.success !== true) throw new Error(payload.message || payload.error || `物理 Schema 诊断失败:${response.status}`); console.log(JSON.stringify({ rest, psql: payload.data }, null, 2)); } finally { if (functionId) await parse(`/classes/Function/${functionId}`, { method: 'DELETE' }).catch(() => undefined); if (userId) { const where = encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: userId } })); const sessions = await parse(`/classes/_Session?where=${where}&limit=1000&keys=objectId`).catch(() => ({ results: [] })); for (const session of sessions.results || []) await parse(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined); await parse(`/users/${userId}`, { method: 'DELETE' }).catch(() => undefined); } }