#!/usr/bin/env node 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 validateOnly = process.argv.includes('--validate'); const adminGatewayCode = String.raw` const CLASS_LABELS = { _User: '用户管理', Company: '帐套管理', Profile: '员工档案', Group: '用户组', Role: '角色', Permission: '权限', Department: '组织部门', CourseBinding: '课程绑定', CourseAppointment: '课程预约', LessonRecord: '上课记录', DailyStudyRecord: '每日学习记录', PracticeRecord: '练习记录', MemoryPracticeRecord: '抗遗忘记录', VocabularyWord: '词库', CommonModel: '通用内容', ContentArticle: '文章内容', ContentPublish: '内容发布', Node: '栏目节点', NodeAuth: '节点权限', Model: '内容模型', ModelField: '模型字段', ExamClass: '考试班级', ExamSysQuestions: '考试题库', ExamSysPapers: '试卷', ExamType: '考试类型', ExamPoint: '知识点', PaperQuestions: '试卷题目', Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台', GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '搜索记录', Agency: '代理机构', DeliveryCenter: '交付中心' }; const ALLOWED_CLASSES = new Set([ 'PageTemplate','PaperQuestions','PayPlat','Permission','PlatComp','App','DesignAnswer','DesignAsk','Attachment','DesignPage','DesignQuestion','DesignRes','Feedback','DesignScence','StudentAchieve','ContentArticle','DesignSiteInfo','Profile','AdInfo','AdZone','ARoleAuth','Baike','ExamSysQuestions','ContactInfo','VocabularyWord','AssessmentProfile','Agency','MemoryPracticeRecord','DeliveryCenter','CourseBinding','PracticeRecord','CourseAppointment','Account','SurveyItem','SurveyLog','LessonRecord','DailyStudyRecord','CommonModel','ContentPublish','Company','_Role','_User','CRMSAttr','Currency','Datadic','Datadiccategory','DesignTlp','DocModel','DocPermission','ExamClass','ExamSysPapers','ExamType','ExamPoint','ExTeacher','FontPicShape','FontPicShapeType','Grade','GradeCate','Group','GroupModel','GuestBar','Guestbook','Guestcate','MailTemp','Manager','MisProcedure','MisProLevel','MisSign','MisType','PageStyle','Model','ModelField','Node','NodeAuth','NodeModelTemplate','Product','PlatUserRole','Pub','PubTw','PubWTHD','PubZXDC','PublishNode','QuestionsKnowledge','Role','StoreProduct','SafeMobile','Search','SenTask','ServiceSeat','ShopFareTlp','ShopMoneyRegular','Special','StoreApplication','StoreStyle','SysCSSManage','SysHoliday','SysLog','Temp','ThirdPlatInfo','UserCredit','UserDummyPoint','UserFriendGroup','UserLevel','UserSIcon','UserUserPoint','UserExpDomP','UserExpHis' ]); const READ_ONLY_CLASSES = new Set(['_Role','Permission','PayPlat','ThirdPlatInfo']); const SYSTEM_FIELDS = new Set(['objectId','createdAt','updatedAt','ACL','company','password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','adminRoleKey','isAdmin','roles','role']); const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']); function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; } function fail(status, message) { const error = new Error(message); error.status = status; throw error; } function pointerId(value) { return value && (value.id || value.objectId || (value.__type === 'Pointer' && value.objectId)); } function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); } function safeValue(value, depth = 0) { if (value == null || depth > 4) return value; if (Array.isArray(value)) return value.map((item) => safeValue(item, depth + 1)); if (value instanceof Date) return value.toISOString(); if (value && typeof value.toJSON === 'function') return safeValue(value.toJSON(), depth + 1); if (typeof value === 'object') { const output = {}; for (const [key, item] of Object.entries(value)) { if (HIDDEN_FIELDS.has(key) || /(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(key)) continue; output[key] = safeValue(item, depth + 1); } return output; } return value; } async function requireAdmin(request) { const current = request.user || (typeof user !== 'undefined' ? user : null); if (!current) fail(401, '管理员会话已失效'); await current.fetch({ useMasterKey: true }); const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : []; const role = String(current.get('role') || ''); const roleKey = String(current.get('adminRoleKey') || ''); const isSuperAdmin = roleKey === 'super-admin' || roles.includes('super-admin'); const isAdmin = current.get('isAdmin') === true || role === 'admin' || roles.includes('admin') || isSuperAdmin; if (!isAdmin) fail(403, '当前账号未被授权为后台管理员'); const company = request.company || current.get('company') || null; if (!company && !isSuperAdmin) fail(403, '管理员账号尚未分配帐套'); return { current, roles, isSuperAdmin, company }; } function assertClass(className) { if (!ALLOWED_CLASSES.has(className)) fail(400, '不允许访问该数据类'); } async function schemaFor(className) { assertClass(className); const schema = await new Parse.Schema(className).get({ useMasterKey: true }); return schema && schema.fields ? schema.fields : {}; } function applyTenant(query, fields, context, requestedCompanyId) { if (!fields.company) return; if (context.isSuperAdmin && requestedCompanyId) { query.equalTo('company', Parse.Object.createWithoutData('Company', String(requestedCompanyId))); } else if (context.company) { query.equalTo('company', context.company); } } function serializeObject(object) { return safeValue(object.toJSON()); } function toParseValue(field, value) { if (value == null || value === '') return value; if (field.type === 'Pointer') { const objectId = pointerId(value) || value; return Parse.Object.createWithoutData(field.targetClass, String(objectId)); } if (field.type === 'Date') { const date = new Date(value.iso || value); if (Number.isNaN(date.getTime())) fail(400, '日期字段格式无效'); return date; } if (field.type === 'Number') { const number = Number(value); if (!Number.isFinite(number)) fail(400, '数字字段格式无效'); return number; } if (field.type === 'Boolean') return value === true || value === 'true'; return value; } async function countClass(className, context) { const fields = await schemaFor(className); const query = new Parse.Query(className); applyTenant(query, fields, context); if (fields.isDeleted) query.notEqualTo('isDeleted', true); return query.count({ useMasterKey: true }); } async function audit(context, action, className, objectId) { try { const log = new Parse.Object('SysLog'); const schema = await new Parse.Schema('SysLog').get({ useMasterKey: true }); const fields = schema.fields || {}; if (fields.company && context.company) log.set('company', context.company); if (fields.userId) log.set('userId', context.current.id); if (fields.userName) log.set('userName', String(context.current.get('username') || '')); if (fields.remind) log.set('remind', '[AngularAdmin] ' + action + ' ' + className + '/' + objectId); if (fields.logType) log.set('logType', 'angular-admin'); await log.save(null, { useMasterKey: true }); } catch (_) { /* 审计表字段来自旧系统,失败不能覆盖主操作结果。 */ } } async function handler(request, response) { try { const input = inputOf(request); const operation = String(input.operation || 'meta'); const context = await requireAdmin(request); if (operation === 'meta') { const functionQuery = new Parse.Query('Function'); functionQuery.notEqualTo('enabled', false); const cloudFunctions = await functionQuery.count({ useMasterKey: true }); return response.json({ success: true, data: { identity: { objectId: context.current.id, username: String(context.current.get('username') || ''), displayName: String(context.current.get('realName') || context.current.get('realname') || context.current.get('nickname') || context.current.get('username') || ''), company: safeValue(context.company), roles: context.roles, isSuperAdmin: context.isSuperAdmin }, classes: ALLOWED_CLASSES.size, cloudFunctions }}); } if (operation === 'dashboard') { const metrics = [ ['_User','用户总数'],['CourseBinding','课程绑定'],['CourseAppointment','课程预约'],['PracticeRecord','练习记录'],['LessonRecord','上课记录'],['DailyStudyRecord','每日学习'],['VocabularyWord','词库单词'],['CommonModel','内容记录'] ]; const counts = await Promise.all(metrics.map(async ([className, label]) => ({ className, label, count: await countClass(className, context), route: '/admin/resources/' + className }))); const functionQuery = new Parse.Query('Function'); functionQuery.notEqualTo('enabled', false); let registeredRecords = 195452; try { const row = await Psql.oneOrNone('SELECT COUNT(*)::int AS count FROM cms.record_registry'); if (row) registeredRecords = Number(row.count); } catch (_) {} return response.json({ success: true, data: { identity: { objectId: context.current.id, username: String(context.current.get('username') || ''), displayName: String(context.current.get('realName') || context.current.get('nickname') || context.current.get('username') || ''), company: safeValue(context.company), roles: context.roles, isSuperAdmin: context.isSuperAdmin }, metrics: counts, migration: { sourceObjects: 338, migratedClasses: 96, registeredRecords, cloudFunctions: await functionQuery.count({ useMasterKey: true }) } } }); } const className = String(input.className || ''); assertClass(className); const fields = await schemaFor(className); const classWritable = !READ_ONLY_CLASSES.has(className); if (operation === 'schema') { const fieldList = Object.entries(fields).filter(([name]) => !HIDDEN_FIELDS.has(name) && !/(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(name)).map(([name, field]) => ({ name, type: field.type, targetClass: field.targetClass, required: field.required === true, writable: classWritable && !SYSTEM_FIELDS.has(name) })); return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, supportsSoftDelete: Boolean(fields.isDeleted) } }); } if (operation === 'list') { const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); if (fields.isDeleted) query.notEqualTo('isDeleted', true); const search = String(input.search || '').trim(); if (search) { const searchField = ['name','title','username','realName','mobile','sourceKey'].find((name) => fields[name] && fields[name].type === 'String'); if (searchField) query.matches(searchField, escapeRegex(search), 'i'); } const sort = fields[input.sort] ? String(input.sort) : fields.updatedAt ? 'updatedAt' : 'createdAt'; if (input.order === 'asc') query.ascending(sort); else query.descending(sort); const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize); const results = await query.find({ useMasterKey: true }); return response.json({ success: true, data: { className, page, pageSize, total, results: results.map(serializeObject) } }); } const objectId = String(input.objectId || ''); if (operation === 'get') { if (!objectId) fail(400, '缺少 objectId'); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const object = await query.get(objectId, { useMasterKey: true }); return response.json({ success: true, data: serializeObject(object) }); } if (operation === 'save') { if (!classWritable) fail(403, '该系统类不允许通用编辑'); if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程'); let object; if (objectId) { const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); object = await query.get(objectId, { useMasterKey: true }); } else object = new Parse.Object(className); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; for (const [name, value] of Object.entries(payload)) { if (!fields[name] || SYSTEM_FIELDS.has(name)) continue; if (value === null) object.unset(name); else object.set(name, toParseValue(fields[name], value)); } if (fields.company && context.company) object.set('company', context.company); if (fields.isDeleted && !objectId) object.set('isDeleted', false); await object.save(null, { useMasterKey: true }); await audit(context, objectId ? 'update' : 'create', className, object.id); return response.json({ success: true, data: serializeObject(object) }); } if (operation === 'delete') { if (!classWritable || !objectId) fail(400, '该记录不允许删除'); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const object = await query.get(objectId, { useMasterKey: true }); if (fields.isDeleted) { object.set('isDeleted', true); await object.save(null, { useMasterKey: true }); } else await object.destroy({ useMasterKey: true }); await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } }); } if (operation === 'resetPassword') { if (className !== '_User' || !objectId || typeof input.newPassword !== 'string' || input.newPassword.length < 8) fail(400, '密码至少 8 位'); const query = new Parse.Query('_User'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); target.setPassword(input.newPassword); if (fields.passwordResetRequired) target.set('passwordResetRequired', false); await target.save(null, { useMasterKey: true }); await audit(context, 'reset-password', '_User', objectId); return response.json({ success: true, data: { objectId } }); } fail(400, '不支持的后台操作'); } catch (error) { const status = Number(error.status || (error.code === 101 ? 404 : 500)); return response.status(status).json({ success: false, error: status >= 500 ? 'cloud_function_error' : 'request_rejected', message: error.message || '云函数执行失败' }); } } `; function cmsReadCode(mode) { return String.raw` const MODE = ${JSON.stringify(mode)}; const HIDDEN = /(?:password|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword)/i; function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; } function fail(status, message) { const error = new Error(message); error.status = status; throw error; } function safe(value, depth = 0) { if (value == null || depth > 4) return value; if (Array.isArray(value)) return value.map((item) => safe(item, depth + 1)); if (value && typeof value.toJSON === 'function') return safe(value.toJSON(), depth + 1); if (typeof value === 'object') { const output = {}; for (const [key,item] of Object.entries(value)) if (!HIDDEN.test(key)) output[key] = safe(item, depth + 1); return output; } return value; } async function auth(request) { const current = request.user || (typeof user !== 'undefined' ? user : null); if (!current) fail(401, '需要登录'); await current.fetch({ useMasterKey: true }); const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : []; const superAdmin = current.get('adminRoleKey') === 'super-admin' || roles.includes('super-admin'); if (!(current.get('isAdmin') === true || current.get('role') === 'admin' || roles.includes('admin') || superAdmin)) fail(403, '需要管理员权限'); const company = request.company || current.get('company') || null; if (!company && !superAdmin) fail(403, '管理员未分配帐套'); return { current, company, superAdmin }; } async function schema(name) { try { return (await new Parse.Schema(name).get({ useMasterKey: true })).fields || {}; } catch (_) { return {}; } } function tenant(query, fields, context) { if (fields.company && context.company) query.equalTo('company', context.company); if (fields.isDeleted) query.notEqualTo('isDeleted', true); } function stripHtml(content) { return String(content == null ? '' : content).replace(//g, ' ').replace(/]*>[\s\S]*?<\/script>/gi, ' ').replace(/]*>[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/ | /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>').replace(/"/gi, '"').replace(/'|'/gi, "'").replace(/\s+/g, ' ').trim(); } function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); } async function handler(request, response) { try { const input = inputOf(request); const context = await auth(request); const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); if (MODE === 'content-normalizer') return response.json({ success: true, data: { content: stripHtml(input.content), mode: String(input.mode || 'strip') } }); if (MODE.startsWith('user-')) { const userId = String(input.userId || ''); if (!userId) fail(400, '缺少 userId'); const fields = await schema('_User'); const query = new Parse.Query('_User'); tenant(query, fields, context); const target = await query.get(userId, { useMasterKey: true }); const data = safe(target); if (MODE === 'user-extended') { delete data.legacyUserPlat; delete data.legacyWxUser; delete data.wechat; delete data.wxapp; return response.json({ success: true, data }); } if (MODE === 'user-platform') return response.json({ success: true, data: { objectId: target.id, platform: safe(target.get('legacyUserPlat') || {}), company: safe(target.get('company')) } }); return response.json({ success: true, data: { objectId: target.id, wechat: safe(target.get('legacyWxUser') || target.get('wechat') || target.get('wxapp') || {}) } }); } if (MODE === 'exam-classes' || MODE === 'guest-bar') { const className = MODE === 'exam-classes' ? 'ExamClass' : 'GuestBar'; const fields = await schema(className); const query = new Parse.Query(className); tenant(query, fields, context); query.descending(fields.updatedAt ? 'updatedAt' : 'createdAt'); const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize); const rows = await query.find({ useMasterKey: true }); return response.json({ success: true, data: { page, pageSize, total, results: rows.map(safe) } }); } if (MODE === 'search') { const keyword = stripHtml(input.keyword); if (!keyword) fail(400, '请输入搜索关键词'); const fields = await schema('CommonModel'); const candidates = ['title','inputer','sourceKey','synopsis','content'].filter((name) => fields[name] && fields[name].type === 'String'); if (!candidates.length) return response.json({ success: true, data: { page, pageSize, total: 0, results: [] } }); const pattern = escapeRegex(keyword); const queries = candidates.slice(0, 3).map((field) => { const query = new Parse.Query('CommonModel'); tenant(query, fields, context); query.matches(field, pattern, 'i'); return query; }); const query = queries.length === 1 ? queries[0] : Parse.Query.or(...queries); query.descending('updatedAt'); query.skip((page - 1) * pageSize); query.limit(pageSize); const rows = await query.find({ useMasterKey: true }); return response.json({ success: true, data: { page, pageSize, total: rows.length < pageSize ? (page - 1) * pageSize + rows.length : null, results: rows.map((row) => { const json = safe(row); if (json.content) json.content = stripHtml(json.content).slice(0, 260); return json; }) } }); } fail(400, '不支持的 CMS 查询'); } catch (error) { const status = Number(error.status || (error.code === 101 ? 404 : 500)); return response.status(status).json({ success: false, error: status >= 500 ? 'cloud_function_error' : 'request_rejected', message: error.message || '云函数执行失败' }); } } `; } const definitions = [ { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode, params: [{ name: 'operation', type: 'String', required: true }] }, { name: 'cms.content-normalizer', desc: '替代 ZL_StripeHtmlTag/ZL_StripeTrimstr,保守输出纯文本', path: 'xiaoshu/cms/content-normalizer', code: cmsReadCode('content-normalizer'), params: [{ name: 'content', type: 'String', required: false }] }, { name: 'cms.users.extended', desc: '替代 ZL_EX_UserView,返回安全用户扩展资料', path: 'xiaoshu/cms/users/extended', code: cmsReadCode('user-extended'), params: [{ name: 'userId', type: 'String', required: true }] }, { name: 'cms.users.platform', desc: '替代 ZL_User_PlatView', path: 'xiaoshu/cms/users/platform', code: cmsReadCode('user-platform'), params: [{ name: 'userId', type: 'String', required: true }] }, { name: 'cms.users.wechat', desc: '替代 ZL_User_WXView,过滤令牌与密钥', path: 'xiaoshu/cms/users/wechat', code: cmsReadCode('user-wechat'), params: [{ name: 'userId', type: 'String', required: true }] }, { name: 'cms.exams.classes', desc: '替代 ZL_Exam_ClassView', path: 'xiaoshu/cms/exams/classes', code: cmsReadCode('exam-classes'), params: [{ name: 'page', type: 'Number', required: false, default: 1 }] }, { name: 'cms.guest.bar', desc: '替代 ZL_Guest_BarView', path: 'xiaoshu/cms/guest/bar', code: cmsReadCode('guest-bar'), params: [{ name: 'page', type: 'Number', required: false, default: 1 }] }, { name: 'cms.search', desc: '替代 ZL_SearchView,搜索规范化 CommonModel', path: 'xiaoshu/cms/search', code: cmsReadCode('search'), params: [{ name: 'keyword', type: 'String', required: true }] }, ]; function validateDefinition(definition) { if (definition.path.startsWith('/')) throw new Error(`${definition.name}: 当前执行器的全局 path 不接受前导 /`); const factory = new Function(`${definition.code}\nreturn typeof handler;`); if (factory() !== 'function') throw new Error(`${definition.name}: 未定义 handler`); } async function parseRequest(path, init = {}) { const response = await fetch(`${PARSE_URL}${path}`, { ...init, headers: { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Master-Key': MASTER_KEY, 'Content-Type': 'application/json', ...(init.headers || {}), }, }); const payload = await response.json(); if (!response.ok || payload.error) throw new Error(payload.error || `Parse 请求失败:${response.status}`); return payload; } async function upsert(definition) { const where = encodeURIComponent(JSON.stringify({ name: definition.name })); const existing = await parseRequest(`/classes/Function?where=${where}&limit=1&keys=objectId`); const body = { name: definition.name, desc: definition.desc, type: 'standalone', path: definition.path, code: definition.code.trim(), params: definition.params, paramList: definition.params, respType: 'json', respJson: { success: true, data: {} }, enabled: true, version: '1.0.0', }; const objectId = existing.results?.[0]?.objectId; if (objectId) { await parseRequest(`/classes/Function/${objectId}`, { method: 'PUT', body: JSON.stringify(body) }); return { action: 'updated', objectId }; } const created = await parseRequest('/classes/Function', { method: 'POST', body: JSON.stringify(body) }); return { action: 'created', objectId: created.objectId }; } for (const definition of definitions) validateDefinition(definition); if (validateOnly) { console.log(`Validated ${definitions.length} cloud function definitions.`); } else { if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY;不会把 masterKey 写入项目文件。'); for (const definition of definitions) { const result = await upsert(definition); console.log(`${result.action.padEnd(7)} ${definition.path} (${result.objectId})`); } }