|
@@ -0,0 +1,294 @@
|
|
|
|
|
+#!/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(/<!--[\s\S]*?-->/g, ' ').replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ').replace(/<style\b[^>]*>[\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})`);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|