deploy-admin-functions.mjs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. #!/usr/bin/env node
  2. const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
  3. const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
  4. const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
  5. const validateOnly = process.argv.includes('--validate');
  6. const adminGatewayCode = String.raw`
  7. const CLASS_LABELS = {
  8. _User: '用户管理', Company: '帐套管理', Profile: '员工档案', Group: '用户组', Role: '角色', Permission: '权限', Department: '组织部门',
  9. CourseBinding: '课程绑定', CourseAppointment: '课程预约', LessonRecord: '上课记录', DailyStudyRecord: '每日学习记录', PracticeRecord: '练习记录', MemoryPracticeRecord: '抗遗忘记录',
  10. VocabularyWord: '词库', CommonModel: '通用内容', ContentArticle: '文章内容', ContentPublish: '内容发布', Node: '栏目节点', NodeAuth: '节点权限', Model: '内容模型', ModelField: '模型字段',
  11. ExamClass: '考试班级', ExamSysQuestions: '考试题库', ExamSysPapers: '试卷', ExamType: '考试类型', ExamPoint: '知识点', PaperQuestions: '试卷题目',
  12. Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
  13. GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '搜索记录', Agency: '代理机构', DeliveryCenter: '交付中心'
  14. };
  15. const ALLOWED_CLASSES = new Set([
  16. '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'
  17. ]);
  18. const READ_ONLY_CLASSES = new Set(['_Role','Permission','PayPlat','ThirdPlatInfo']);
  19. 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']);
  20. const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
  21. function inputOf(request) {
  22. const body = request.body || {};
  23. return body.params && typeof body.params === 'object' ? body.params : body;
  24. }
  25. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  26. function pointerId(value) { return value && (value.id || value.objectId || (value.__type === 'Pointer' && value.objectId)); }
  27. function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
  28. function safeValue(value, depth = 0) {
  29. if (value == null || depth > 4) return value;
  30. if (Array.isArray(value)) return value.map((item) => safeValue(item, depth + 1));
  31. if (value instanceof Date) return value.toISOString();
  32. if (value && typeof value.toJSON === 'function') return safeValue(value.toJSON(), depth + 1);
  33. if (typeof value === 'object') {
  34. const output = {};
  35. for (const [key, item] of Object.entries(value)) {
  36. if (HIDDEN_FIELDS.has(key) || /(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(key)) continue;
  37. output[key] = safeValue(item, depth + 1);
  38. }
  39. return output;
  40. }
  41. return value;
  42. }
  43. async function requireAdmin(request) {
  44. const current = request.user || (typeof user !== 'undefined' ? user : null);
  45. if (!current) fail(401, '管理员会话已失效');
  46. await current.fetch({ useMasterKey: true });
  47. const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
  48. const role = String(current.get('role') || '');
  49. const roleKey = String(current.get('adminRoleKey') || '');
  50. const isSuperAdmin = roleKey === 'super-admin' || roles.includes('super-admin');
  51. const isAdmin = current.get('isAdmin') === true || role === 'admin' || roles.includes('admin') || isSuperAdmin;
  52. if (!isAdmin) fail(403, '当前账号未被授权为后台管理员');
  53. const company = request.company || current.get('company') || null;
  54. if (!company && !isSuperAdmin) fail(403, '管理员账号尚未分配帐套');
  55. return { current, roles, isSuperAdmin, company };
  56. }
  57. function assertClass(className) {
  58. if (!ALLOWED_CLASSES.has(className)) fail(400, '不允许访问该数据类');
  59. }
  60. async function schemaFor(className) {
  61. assertClass(className);
  62. const schema = await new Parse.Schema(className).get({ useMasterKey: true });
  63. return schema && schema.fields ? schema.fields : {};
  64. }
  65. function applyTenant(query, fields, context, requestedCompanyId) {
  66. if (!fields.company) return;
  67. if (context.isSuperAdmin && requestedCompanyId) {
  68. query.equalTo('company', Parse.Object.createWithoutData('Company', String(requestedCompanyId)));
  69. } else if (context.company) {
  70. query.equalTo('company', context.company);
  71. }
  72. }
  73. function serializeObject(object) { return safeValue(object.toJSON()); }
  74. function toParseValue(field, value) {
  75. if (value == null || value === '') return value;
  76. if (field.type === 'Pointer') {
  77. const objectId = pointerId(value) || value;
  78. return Parse.Object.createWithoutData(field.targetClass, String(objectId));
  79. }
  80. if (field.type === 'Date') {
  81. const date = new Date(value.iso || value);
  82. if (Number.isNaN(date.getTime())) fail(400, '日期字段格式无效');
  83. return date;
  84. }
  85. if (field.type === 'Number') {
  86. const number = Number(value);
  87. if (!Number.isFinite(number)) fail(400, '数字字段格式无效');
  88. return number;
  89. }
  90. if (field.type === 'Boolean') return value === true || value === 'true';
  91. return value;
  92. }
  93. async function countClass(className, context) {
  94. const fields = await schemaFor(className);
  95. const query = new Parse.Query(className);
  96. applyTenant(query, fields, context);
  97. if (fields.isDeleted) query.notEqualTo('isDeleted', true);
  98. return query.count({ useMasterKey: true });
  99. }
  100. async function audit(context, action, className, objectId) {
  101. try {
  102. const log = new Parse.Object('SysLog');
  103. const schema = await new Parse.Schema('SysLog').get({ useMasterKey: true });
  104. const fields = schema.fields || {};
  105. if (fields.company && context.company) log.set('company', context.company);
  106. if (fields.userId) log.set('userId', context.current.id);
  107. if (fields.userName) log.set('userName', String(context.current.get('username') || ''));
  108. if (fields.remind) log.set('remind', '[AngularAdmin] ' + action + ' ' + className + '/' + objectId);
  109. if (fields.logType) log.set('logType', 'angular-admin');
  110. await log.save(null, { useMasterKey: true });
  111. } catch (_) { /* 审计表字段来自旧系统,失败不能覆盖主操作结果。 */ }
  112. }
  113. async function handler(request, response) {
  114. try {
  115. const input = inputOf(request);
  116. const operation = String(input.operation || 'meta');
  117. const context = await requireAdmin(request);
  118. if (operation === 'meta') {
  119. const functionQuery = new Parse.Query('Function');
  120. functionQuery.notEqualTo('enabled', false);
  121. const cloudFunctions = await functionQuery.count({ useMasterKey: true });
  122. return response.json({ success: true, data: {
  123. 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 },
  124. classes: ALLOWED_CLASSES.size, cloudFunctions
  125. }});
  126. }
  127. if (operation === 'dashboard') {
  128. const metrics = [
  129. ['_User','用户总数'],['CourseBinding','课程绑定'],['CourseAppointment','课程预约'],['PracticeRecord','练习记录'],['LessonRecord','上课记录'],['DailyStudyRecord','每日学习'],['VocabularyWord','词库单词'],['CommonModel','内容记录']
  130. ];
  131. const counts = await Promise.all(metrics.map(async ([className, label]) => ({ className, label, count: await countClass(className, context), route: '/admin/resources/' + className })));
  132. const functionQuery = new Parse.Query('Function'); functionQuery.notEqualTo('enabled', false);
  133. let registeredRecords = 195452;
  134. try { const row = await Psql.oneOrNone('SELECT COUNT(*)::int AS count FROM cms.record_registry'); if (row) registeredRecords = Number(row.count); } catch (_) {}
  135. 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 }) } } });
  136. }
  137. const className = String(input.className || '');
  138. assertClass(className);
  139. const fields = await schemaFor(className);
  140. const classWritable = !READ_ONLY_CLASSES.has(className);
  141. if (operation === 'schema') {
  142. 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) }));
  143. return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, supportsSoftDelete: Boolean(fields.isDeleted) } });
  144. }
  145. if (operation === 'list') {
  146. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
  147. let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); if (fields.isDeleted) query.notEqualTo('isDeleted', true);
  148. const search = String(input.search || '').trim();
  149. 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'); }
  150. const sort = fields[input.sort] ? String(input.sort) : fields.updatedAt ? 'updatedAt' : 'createdAt'; if (input.order === 'asc') query.ascending(sort); else query.descending(sort);
  151. const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize);
  152. const results = await query.find({ useMasterKey: true });
  153. return response.json({ success: true, data: { className, page, pageSize, total, results: results.map(serializeObject) } });
  154. }
  155. const objectId = String(input.objectId || '');
  156. if (operation === 'get') {
  157. 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 });
  158. return response.json({ success: true, data: serializeObject(object) });
  159. }
  160. if (operation === 'save') {
  161. if (!classWritable) fail(403, '该系统类不允许通用编辑');
  162. if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程');
  163. let object;
  164. 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);
  165. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  166. 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)); }
  167. if (fields.company && context.company) object.set('company', context.company); if (fields.isDeleted && !objectId) object.set('isDeleted', false);
  168. await object.save(null, { useMasterKey: true }); await audit(context, objectId ? 'update' : 'create', className, object.id);
  169. return response.json({ success: true, data: serializeObject(object) });
  170. }
  171. if (operation === 'delete') {
  172. 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 });
  173. if (fields.isDeleted) { object.set('isDeleted', true); await object.save(null, { useMasterKey: true }); } else await object.destroy({ useMasterKey: true });
  174. await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } });
  175. }
  176. if (operation === 'resetPassword') {
  177. if (className !== '_User' || !objectId || typeof input.newPassword !== 'string' || input.newPassword.length < 8) fail(400, '密码至少 8 位');
  178. 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 } });
  179. }
  180. fail(400, '不支持的后台操作');
  181. } catch (error) {
  182. const status = Number(error.status || (error.code === 101 ? 404 : 500));
  183. return response.status(status).json({ success: false, error: status >= 500 ? 'cloud_function_error' : 'request_rejected', message: error.message || '云函数执行失败' });
  184. }
  185. }
  186. `;
  187. function cmsReadCode(mode) {
  188. return String.raw`
  189. const MODE = ${JSON.stringify(mode)};
  190. const HIDDEN = /(?:password|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword)/i;
  191. function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; }
  192. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  193. 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; }
  194. 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 }; }
  195. async function schema(name) { try { return (await new Parse.Schema(name).get({ useMasterKey: true })).fields || {}; } catch (_) { return {}; } }
  196. function tenant(query, fields, context) { if (fields.company && context.company) query.equalTo('company', context.company); if (fields.isDeleted) query.notEqualTo('isDeleted', true); }
  197. 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(/&nbsp;|&#160;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').replace(/&quot;/gi, '"').replace(/&#39;|&apos;/gi, "'").replace(/\s+/g, ' ').trim(); }
  198. function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
  199. async function handler(request, response) {
  200. try {
  201. 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));
  202. if (MODE === 'content-normalizer') return response.json({ success: true, data: { content: stripHtml(input.content), mode: String(input.mode || 'strip') } });
  203. if (MODE.startsWith('user-')) {
  204. 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);
  205. if (MODE === 'user-extended') { delete data.legacyUserPlat; delete data.legacyWxUser; delete data.wechat; delete data.wxapp; return response.json({ success: true, data }); }
  206. if (MODE === 'user-platform') return response.json({ success: true, data: { objectId: target.id, platform: safe(target.get('legacyUserPlat') || {}), company: safe(target.get('company')) } });
  207. return response.json({ success: true, data: { objectId: target.id, wechat: safe(target.get('legacyWxUser') || target.get('wechat') || target.get('wxapp') || {}) } });
  208. }
  209. if (MODE === 'exam-classes' || MODE === 'guest-bar') {
  210. 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) } });
  211. }
  212. if (MODE === 'search') {
  213. 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: [] } });
  214. 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; }) } });
  215. }
  216. fail(400, '不支持的 CMS 查询');
  217. } 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 || '云函数执行失败' }); }
  218. }
  219. `;
  220. }
  221. const definitions = [
  222. { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode, params: [{ name: 'operation', type: 'String', required: true }] },
  223. { 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 }] },
  224. { name: 'cms.users.extended', desc: '替代 ZL_EX_UserView,返回安全用户扩展资料', path: 'xiaoshu/cms/users/extended', code: cmsReadCode('user-extended'), params: [{ name: 'userId', type: 'String', required: true }] },
  225. { name: 'cms.users.platform', desc: '替代 ZL_User_PlatView', path: 'xiaoshu/cms/users/platform', code: cmsReadCode('user-platform'), params: [{ name: 'userId', type: 'String', required: true }] },
  226. { name: 'cms.users.wechat', desc: '替代 ZL_User_WXView,过滤令牌与密钥', path: 'xiaoshu/cms/users/wechat', code: cmsReadCode('user-wechat'), params: [{ name: 'userId', type: 'String', required: true }] },
  227. { 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 }] },
  228. { 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 }] },
  229. { name: 'cms.search', desc: '替代 ZL_SearchView,搜索规范化 CommonModel', path: 'xiaoshu/cms/search', code: cmsReadCode('search'), params: [{ name: 'keyword', type: 'String', required: true }] },
  230. ];
  231. function validateDefinition(definition) {
  232. if (definition.path.startsWith('/')) throw new Error(`${definition.name}: 当前执行器的全局 path 不接受前导 /`);
  233. const factory = new Function(`${definition.code}\nreturn typeof handler;`);
  234. if (factory() !== 'function') throw new Error(`${definition.name}: 未定义 handler`);
  235. }
  236. async function parseRequest(path, init = {}) {
  237. const response = await fetch(`${PARSE_URL}${path}`, {
  238. ...init,
  239. headers: {
  240. 'X-Parse-Application-Id': APP_ID,
  241. 'X-Parse-Master-Key': MASTER_KEY,
  242. 'Content-Type': 'application/json',
  243. ...(init.headers || {}),
  244. },
  245. });
  246. const payload = await response.json();
  247. if (!response.ok || payload.error) throw new Error(payload.error || `Parse 请求失败:${response.status}`);
  248. return payload;
  249. }
  250. async function upsert(definition) {
  251. const where = encodeURIComponent(JSON.stringify({ name: definition.name }));
  252. const existing = await parseRequest(`/classes/Function?where=${where}&limit=1&keys=objectId`);
  253. const body = {
  254. name: definition.name,
  255. desc: definition.desc,
  256. type: 'standalone',
  257. path: definition.path,
  258. code: definition.code.trim(),
  259. params: definition.params,
  260. paramList: definition.params,
  261. respType: 'json',
  262. respJson: { success: true, data: {} },
  263. enabled: true,
  264. version: '1.0.0',
  265. };
  266. const objectId = existing.results?.[0]?.objectId;
  267. if (objectId) {
  268. await parseRequest(`/classes/Function/${objectId}`, { method: 'PUT', body: JSON.stringify(body) });
  269. return { action: 'updated', objectId };
  270. }
  271. const created = await parseRequest('/classes/Function', { method: 'POST', body: JSON.stringify(body) });
  272. return { action: 'created', objectId: created.objectId };
  273. }
  274. for (const definition of definitions) validateDefinition(definition);
  275. if (validateOnly) {
  276. console.log(`Validated ${definitions.length} cloud function definitions.`);
  277. } else {
  278. if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY;不会把 masterKey 写入项目文件。');
  279. for (const definition of definitions) {
  280. const result = await upsert(definition);
  281. console.log(`${result.action.padEnd(7)} ${definition.path} (${result.objectId})`);
  282. }
  283. }