deploy-admin-functions.mjs 76 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  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. function isVisible(object) { const value = object && typeof object.get === 'function' ? object.get('isDeleted') : object && object.isDeleted; return ![true, 1, '1', 'true', 'True', 'TRUE'].includes(value); }
  44. async function requireAdmin(request) {
  45. const current = request.user || (typeof user !== 'undefined' ? user : null);
  46. if (!current) fail(401, '管理员会话已失效');
  47. await current.fetch({ useMasterKey: true });
  48. const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
  49. const role = String(current.get('role') || '');
  50. const roleKey = String(current.get('adminRoleKey') || '');
  51. const isSuperAdmin = roleKey === 'super-admin' || roles.includes('super-admin');
  52. const isAdmin = current.get('isAdmin') === true || role === 'admin' || roles.includes('admin') || isSuperAdmin;
  53. if (!isAdmin) fail(403, '当前账号未被授权为后台管理员');
  54. const company = request.company || current.get('company') || null;
  55. if (!company && !isSuperAdmin) fail(403, '管理员账号尚未分配帐套');
  56. return { current, roles, isSuperAdmin, company };
  57. }
  58. function assertClass(className) {
  59. if (!ALLOWED_CLASSES.has(className)) fail(400, '不允许访问该数据类');
  60. }
  61. async function schemaFor(className) {
  62. assertClass(className);
  63. const schema = await new Parse.Schema(className).get({ useMasterKey: true });
  64. return schema && schema.fields ? schema.fields : {};
  65. }
  66. function applyTenant(query, fields, context, requestedCompanyId) {
  67. if (!fields.company) return;
  68. if (context.isSuperAdmin && requestedCompanyId) {
  69. query.equalTo('company', Parse.Object.createWithoutData('Company', String(requestedCompanyId)));
  70. } else if (context.company) {
  71. query.equalTo('company', context.company);
  72. }
  73. }
  74. function serializeObject(object) { return safeValue(object.toJSON()); }
  75. function toParseValue(field, value) {
  76. if (value == null || value === '') return value;
  77. if (field.type === 'Pointer') {
  78. const objectId = pointerId(value) || value;
  79. return Parse.Object.createWithoutData(field.targetClass, String(objectId));
  80. }
  81. if (field.type === 'Date') {
  82. const date = new Date(value.iso || value);
  83. if (Number.isNaN(date.getTime())) fail(400, '日期字段格式无效');
  84. return date;
  85. }
  86. if (field.type === 'Number') {
  87. const number = Number(value);
  88. if (!Number.isFinite(number)) fail(400, '数字字段格式无效');
  89. return number;
  90. }
  91. if (field.type === 'Boolean') return value === true || value === 'true';
  92. return value;
  93. }
  94. async function countClass(className, context) {
  95. const fields = await schemaFor(className);
  96. const query = new Parse.Query(className);
  97. applyTenant(query, fields, context);
  98. if (fields.isDeleted) query.notEqualTo('isDeleted', true);
  99. return query.count({ useMasterKey: true });
  100. }
  101. async function audit(context, action, className, objectId) {
  102. try {
  103. const log = new Parse.Object('SysLog');
  104. const schema = await new Parse.Schema('SysLog').get({ useMasterKey: true });
  105. const fields = schema.fields || {};
  106. if (fields.company && context.company) log.set('company', context.company);
  107. if (fields.userId) log.set('userId', context.current.id);
  108. if (fields.userName) log.set('userName', String(context.current.get('username') || ''));
  109. if (fields.remind) log.set('remind', '[AngularAdmin] ' + action + ' ' + className + '/' + objectId);
  110. if (fields.logType) log.set('logType', 'angular-admin');
  111. await log.save(null, { useMasterKey: true });
  112. } catch (_) { /* 审计表字段来自旧系统,失败不能覆盖主操作结果。 */ }
  113. }
  114. async function handler(request, response) {
  115. try {
  116. const input = inputOf(request);
  117. const operation = String(input.operation || 'meta');
  118. const context = await requireAdmin(request);
  119. if (operation === 'meta') {
  120. const functionQuery = new Parse.Query('Function');
  121. functionQuery.notEqualTo('enabled', false);
  122. const cloudFunctions = await functionQuery.count({ useMasterKey: true });
  123. return response.json({ success: true, data: {
  124. 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 },
  125. classes: ALLOWED_CLASSES.size, cloudFunctions
  126. }});
  127. }
  128. if (operation === 'dashboard') {
  129. const metrics = [
  130. ['_User','用户总数'],['CourseBinding','课程绑定'],['CourseAppointment','课程预约'],['PracticeRecord','练习记录'],['LessonRecord','上课记录'],['DailyStudyRecord','每日学习'],['VocabularyWord','词库单词'],['CommonModel','内容记录']
  131. ];
  132. const counts = await Promise.all(metrics.map(async ([className, label]) => ({ className, label, count: await countClass(className, context), route: '/admin/resources/' + className })));
  133. const functionQuery = new Parse.Query('Function'); functionQuery.notEqualTo('enabled', false);
  134. let registeredRecords = 195452;
  135. try { const row = await Psql.oneOrNone('SELECT COUNT(*)::int AS count FROM cms.record_registry'); if (row) registeredRecords = Number(row.count); } catch (_) {}
  136. 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 }) } } });
  137. }
  138. const className = String(input.className || '');
  139. assertClass(className);
  140. const fields = await schemaFor(className);
  141. const classWritable = !READ_ONLY_CLASSES.has(className);
  142. if (operation === 'schema') {
  143. 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) }));
  144. return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, supportsSoftDelete: Boolean(fields.isDeleted) } });
  145. }
  146. if (operation === 'list') {
  147. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
  148. let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId);
  149. const search = String(input.search || '').trim();
  150. 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'); }
  151. const sort = fields[input.sort] ? String(input.sort) : fields.updatedAt ? 'updatedAt' : 'createdAt'; if (input.order === 'asc') query.ascending(sort); else query.descending(sort);
  152. const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize);
  153. const results = (await query.find({ useMasterKey: true })).filter(isVisible);
  154. return response.json({ success: true, data: { className, page, pageSize, total, results: results.map(serializeObject) } });
  155. }
  156. const objectId = String(input.objectId || '');
  157. if (operation === 'get') {
  158. 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 });
  159. return response.json({ success: true, data: serializeObject(object) });
  160. }
  161. if (operation === 'save') {
  162. if (!classWritable) fail(403, '该系统类不允许通用编辑');
  163. if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程');
  164. let object;
  165. 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);
  166. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  167. 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)); }
  168. if (fields.company && context.company) object.set('company', context.company); if (fields.isDeleted && !objectId) object.set('isDeleted', false);
  169. await object.save(null, { useMasterKey: true }); await audit(context, objectId ? 'update' : 'create', className, object.id);
  170. return response.json({ success: true, data: serializeObject(object) });
  171. }
  172. if (operation === 'delete') {
  173. 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 });
  174. if (fields.isDeleted) { object.set('isDeleted', true); await object.save(null, { useMasterKey: true }); } else await object.destroy({ useMasterKey: true });
  175. await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } });
  176. }
  177. if (operation === 'resetPassword') {
  178. if (className !== '_User' || !objectId || typeof input.newPassword !== 'string' || input.newPassword.length < 8) fail(400, '密码至少 8 位');
  179. 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 } });
  180. }
  181. fail(400, '不支持的后台操作');
  182. } catch (error) {
  183. const status = Number(error.status || (error.code === 101 ? 404 : 500));
  184. return response.status(status).json({ success: false, error: status >= 500 ? 'cloud_function_error' : 'request_rejected', message: error.message || '云函数执行失败' });
  185. }
  186. }
  187. `;
  188. function cmsReadCode(mode) {
  189. return String.raw`
  190. const MODE = ${JSON.stringify(mode)};
  191. const HIDDEN = /(?:password|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword)/i;
  192. function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; }
  193. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  194. 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; }
  195. 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 }; }
  196. async function schema(name) { try { return (await new Parse.Schema(name).get({ useMasterKey: true })).fields || {}; } catch (_) { return {}; } }
  197. function tenant(query, fields, context) { if (fields.company && context.company) query.equalTo('company', context.company); }
  198. 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(); }
  199. function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
  200. async function handler(request, response) {
  201. try {
  202. 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));
  203. if (MODE === 'content-normalizer') return response.json({ success: true, data: { content: stripHtml(input.content), mode: String(input.mode || 'strip') } });
  204. if (MODE.startsWith('user-')) {
  205. 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);
  206. if (MODE === 'user-extended') { delete data.legacyUserPlat; delete data.legacyWxUser; delete data.wechat; delete data.wxapp; return response.json({ success: true, data }); }
  207. if (MODE === 'user-platform') return response.json({ success: true, data: { objectId: target.id, platform: safe(target.get('legacyUserPlat') || {}), company: safe(target.get('company')) } });
  208. return response.json({ success: true, data: { objectId: target.id, wechat: safe(target.get('legacyWxUser') || target.get('wechat') || target.get('wxapp') || {}) } });
  209. }
  210. if (MODE === 'exam-classes' || MODE === 'guest-bar') {
  211. 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) } });
  212. }
  213. if (MODE === 'search') {
  214. 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: [] } });
  215. 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; }) } });
  216. }
  217. fail(400, '不支持的 CMS 查询');
  218. } 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 || '云函数执行失败' }); }
  219. }
  220. `;
  221. }
  222. const appGatewayCode = String.raw`
  223. const DEFAULT_COMPANY_ID = '7pIbDBJmKx';
  224. const PUBLIC_READ = new Set(['content_list','content_get','node_list','node_get','product_list','product_get','app_update']);
  225. const BLOCKED = {
  226. mcode_send: '缺少新短信服务商凭据与验证码存储', user_login_mobile: '缺少新短信验证码服务', user_register_mobile: '缺少新短信验证码服务', user_sync2: '依赖微信容器授权与新版微信凭据',
  227. cart_list: '目标 Schema 无购物车类', coupon_list: '目标 Schema 无优惠券实例类', coupon_usrgot_add: '目标 Schema 无用户优惠券类', coupon_usrgot_list: '目标 Schema 无用户优惠券类',
  228. order_comment_add: '目标 Schema 无订单评价类', order_comment_list: '目标 Schema 无订单评价类', order_delivery: '目标 Schema 无订单与物流类', order_get: '目标 Schema 无订单类', order_list: '目标 Schema 无订单类', order_signfor: '目标 Schema 无订单类',
  229. payment_cart: '目标 Schema 无订单与支付明细类', payment_cart_again: '目标 Schema 无订单与支付明细类', payment_success: '目标 Schema 无支付明细类',
  230. invoice_add: '目标 Schema 无发票类', invoice_del: '目标 Schema 无发票类', invoice_get: '目标 Schema 无发票类', invoice_list: '目标 Schema 无发票类', invoice_upd: '目标 Schema 无发票类',
  231. receaddr_add: '目标 Schema 无收货地址类', receaddr_del: '目标 Schema 无收货地址类', receaddr_get: '目标 Schema 无收货地址类', receaddr_list: '目标 Schema 无收货地址类', receaddr_upd: '目标 Schema 无收货地址类',
  232. user_bank_add: '目标 Schema 无提现账户类', user_bank_del: '目标 Schema 无提现账户类', user_bank_get: '目标 Schema 无提现账户类', user_bank_list: '目标 Schema 无提现账户类', user_cash_add: '缺少提现事务账本', user_cash_list: '缺少提现事务账本',
  233. user_coin_recharge: '缺少支付凭据与事务账本', user_exp_transfer: '缺少积分事务规则确认', user_money_recharge: '缺少支付凭据与事务账本', user_money_transfer: '缺少资金事务规则确认',
  234. user_group_usr_supply: '目标 Schema 无会员续费订单类', user_group_usr_upgrade: '目标 Schema 无会员升级订单类', user_shop_order: '目标 Schema 无订单类', user_shop_sales: '目标 Schema 无销售明细类',
  235. user_star_add: '目标 Schema 无收藏关系类', user_star_del: '目标 Schema 无收藏关系类', user_star_is: '目标 Schema 无收藏关系类', user_update_paypwd: '需要独立支付密码哈希服务',
  236. vote_add: '目标 Schema 无可证明的投票记录类', vote_ask: '目标 Schema 无可证明的投票记录类', vote_question: '目标 Schema 无可证明的投票记录类',
  237. content_add: 'content/addon 双表写入关系尚待字段级回归', content_add_zt: 'content/addon 双表写入关系尚待字段级回归', content_update: 'content/addon 双表写入关系尚待字段级回归',
  238. product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
  239. unit_record_update: '旧源码在调用前无条件 return;需确认是否废弃或提供新的单元聚合规则', e_order_update_v2: '状态 11 还需原子扣减课时、写余额账本并创建 21 天抗遗忘记录;云运行时未提供事务',
  240. e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
  241. pub_add: '需确认 Pub 业务类型与审核规则', pub_list: '需确认 Pub 业务类型与可见范围', user_dept: '目标 Schema 无可证明的用户部门关系',
  242. user_rnauth_add: '实名认证需新的合规审核与敏感数据存储', user_rnauth_get: '实名认证需新的合规审核与敏感数据存储', user_rnauth_upd: '实名认证需新的合规审核与敏感数据存储',
  243. product_add: '需建立门店管理员授权与商品附加表事务', product_upd: '需建立门店管理员授权与商品附加表事务'
  244. };
  245. const SENSITIVE = /(?:password|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword|payPassword)/i;
  246. function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; }
  247. function envelope(result, addon, page) { const value = { retcode: 0, retmsg: '', result }; if (addon !== undefined) value.addon = addon; if (page) value.page = page; return value; }
  248. function reject(message) { return { retcode: -1, retmsg: message, result: null }; }
  249. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  250. function safe(value, depth = 0) { if (value == null || depth > 4) return value; if (value instanceof Date) return value.toISOString(); 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 (!SENSITIVE.test(key)) output[key] = safe(item, depth + 1); return output; } return value; }
  251. function number(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }
  252. function pageInput(input) { return { index: Math.max(1, number(input.cpage || input.page, 1)), size: Math.min(100, Math.max(1, number(input.psize || input.pageSize, 20))) }; }
  253. function companyPointer() { return Parse.Object.createWithoutData('Company', DEFAULT_COMPANY_ID); }
  254. async function fieldsOf(className) { return (await new Parse.Schema(className).get({ useMasterKey: true })).fields || {}; }
  255. function tenant(query, fields) { if (fields.company) query.equalTo('company', companyPointer()); }
  256. function isVisible(row) { const value = row && typeof row.get === 'function' ? row.get('isDeleted') : row && row.isDeleted; return ![true, 1, '1', 'true', 'True', 'TRUE'].includes(value); }
  257. function legacyAliases(value, className) {
  258. const row = safe(value); const maps = {
  259. CommonModel: { GeneralID:'generalId', OrderID:'orderId', NodeID:'nodeId', ModelID:'modelId', ItemID:'itemId', TableName:'tableName', Title:'title', Inputer:'inputer', Hits:'hits', CreateTime:'createTime', Status:'status', TopImg:'topImg', Subtitle:'subtitle' },
  260. Node: { NodeID:'nodeId', NodeName:'nodeName', NodeType:'nodeType', NodeDir:'nodeDir', NodeUrl:'nodeUrl', ParentID:'parentId', OrderID:'orderId', NodePic:'nodePicUrl', Description:'description' },
  261. Product: { ID:'id', Stock:'nums' }, App: { ID:'id' }
  262. }; const map = maps[className] || {}; for (const [legacy,source] of Object.entries(map)) if (row[legacy] === undefined && row[source] !== undefined) row[legacy] = row[source]; return row;
  263. }
  264. async function currentUser(request, required = true) { const current = request.user || (typeof user !== 'undefined' ? user : null); if (!current) { if (required) fail(401, '登录状态已失效'); return null; } await current.fetch({ useMasterKey: true }); const company = current.get('company'); if (company && company.id !== DEFAULT_COMPANY_ID) fail(403, '用户不属于小树英语帐套'); return current; }
  265. function legacyUser(current, withToken = false) { const json = safe(current); const value = { ...json, userId: number(current.get('legacyUserId') || current.get('userid') || current.get('num')), userName: String(current.get('username') || ''), honeyName: String(current.get('nickname') || current.get('nickName') || current.get('realName') || current.get('username') || ''), userFace: String(current.get('avatar') || ''), mobile: String(current.get('mobile') || current.get('phone') || ''), groupId: number(current.get('legacyGroupId')), groupName: String(current.get('roleName') || '') }; if (withToken) value.sessionToken = current.getSessionToken(); return value; }
  266. async function findByLegacyId(className, fields, legacyId, aliases) { const query = new Parse.Query(className); tenant(query, fields); const field = aliases.find((name) => fields[name]); if (!field) return null; query.equalTo(field, number(legacyId)); return query.first({ useMasterKey: true }); }
  267. async function paged(className, input, configure) { const fields = await fieldsOf(className); const paging = pageInput(input); const query = new Parse.Query(className); tenant(query, fields); if (configure) configure(query, fields); const total = await query.count({ useMasterKey: true }); query.skip((paging.index - 1) * paging.size); query.limit(paging.size); query.descending(fields.updatedAt ? 'updatedAt' : 'createdAt'); const rows = (await query.find({ useMasterKey: true })).filter(isVisible); return { rows: rows.map(safe), page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } }; }
  268. const CONTENT_ADDONS = {
  269. 52: { className:'VocabularyWord', fields:['id','lj','sy','yb','yp'] },
  270. 53: { className:'PracticeRecord', fields:['id','kcid','scid','xxcs','yhid','jrscb'] },
  271. 54: { className:'CourseAppointment', fields:['id','pl','bxrq','dslx','dszt','fxpl','jffs','jssj','kcid','kssj','plxm','scsj','sdsd','szmd','szyh','yysj','yykcid'] },
  272. 56: { className:'DailyStudyRecord', fields:['id','pl','con','djq','ygg','dqrq','dsid','fxrl','xxqs','szmdid','userId','learned'] },
  273. 58: { className:'CourseBinding', fields:['id','yxx','cksl','kcid','syjd','yhid'] },
  274. 59: { className:'LessonRecord', fields:['id','pf','jffs','jsmz','kcid','kclx','kcmc','kzsj','pjnr','pldp','xymz','yyds','plpjsj','szmdid'] },
  275. 60: { className:'MemoryPracticeRecord', fields:['id','fxzt','kcid','plid','wcsj','yhid','kywrq','kywsj','xxjlid','orderId'] },
  276. 61: { className:'AssessmentProfile', fields:['id','df','askid','wrong','userId','answerid','dontKnow','prevScore','totalScore'] }
  277. };
  278. const NODE_MODELS = { 28:58, 29:54, 32:53, 291:56, 296:59, 327:52, 388:60, 389:61 };
  279. const PRIVATE_CONTENT_MODELS = new Set([53,54,56,58,59,60,61]);
  280. function contentModel(input, nodeIds) { const explicit = number(input.modelId || input.modelid || input.ModelID); return explicit || (nodeIds.length === 1 ? NODE_MODELS[number(nodeIds[0])] || 0 : 0); }
  281. function requestedContentModel(input) { const nodes = String(input.nodeid || input.nid || input.nodes || '').split(',').map(number).filter(Boolean); return contentModel(input, nodes); }
  282. function legacyFilterPairs(input) {
  283. const source = [input.myfield, input.myfield2].filter(Boolean).join('|'); if (!source) return [];
  284. return source.split('|').map((part) => { const match = String(part).trim().match(/^([A-Za-z][A-Za-z0-9_.]*)=(.*)$/); if (!match) fail(400, '内容过滤条件格式错误'); return { name: match[1].replace(/^B\./i,''), value: match[2] }; });
  285. }
  286. function addonField(config, requested) { return config && config.fields.find((field) => field.toLowerCase() === String(requested).toLowerCase()); }
  287. function contentQueryRequiresAuth(action, input) { return (action === 'content_list' || action === 'content_list_llk') && (PRIVATE_CONTENT_MODELS.has(requestedContentModel(input)) || legacyFilterPairs(input).length > 0); }
  288. async function authorizeContentAccess(current, input) {
  289. if (!PRIVATE_CONTENT_MODELS.has(requestedContentModel(input))) return; const pairs = legacyFilterPairs(input); await authorizeOwnerPairs(current, pairs.map((pair) => ({ name: pair.name.toLowerCase(), value: number(pair.value) })).filter((pair) => pair.value));
  290. }
  291. function isAdminUser(current) { const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : []; return current.get('isAdmin') === true || current.get('role') === 'admin' || roles.includes('admin'); }
  292. async function authorizeOwnerPairs(current, pairs) {
  293. if (isAdminUser(current)) return; const owners = pairs.filter((pair) => ['userid','yhid','xymz','jsmz','szyh'].includes(pair.name)); if (!owners.length) fail(403, '私有内容查询必须限定用户');
  294. const ownId = number(current.get('legacyUserId') || current.get('userid') || current.get('num')); const subjects = owners.filter((pair) => ['userid','yhid','xymz'].includes(pair.name)); if (subjects.some((pair) => pair.value === ownId)) return;
  295. const targets = subjects.length ? subjects : owners; const fields = await fieldsOf('_User'); for (const targetId of [...new Set(targets.map((pair) => pair.value))]) { if (targetId === ownId) continue; const target = await findByLegacyId('_User', fields, targetId, ['legacyUserId','userid','num']); const agent = target && target.get('agent'); if (!target || !agent || agent.id !== current.id) fail(403, '无权查看该学员的内容记录'); }
  296. }
  297. async function authorizeContentDetail(current, detail) { const pairs = ['userId','yhid','xymz','jsmz','szyh'].map((name) => ({ name: name.toLowerCase(), value: number(detail[name] ?? detail[name === 'userId' ? 'UserID' : name]) })).filter((pair) => pair.value); await authorizeOwnerPairs(current, pairs); }
  298. function contentOrder(input, config) {
  299. const raw = String(input.orders || '').trim(); if (!raw) return 'c."updatedAt" DESC'; if (/^NEWID\(\)$/i.test(raw)) return 'RANDOM()';
  300. const match = raw.match(/^([A-Za-z][A-Za-z0-9_.]*)\s*=\s*(ASC|DESC)$/i); if (!match) return 'c."updatedAt" DESC'; const requested = match[1].replace(/^B\./i,''); const direction = match[2].toUpperCase();
  301. const addon = addonField(config, requested); if (addon) return 'a."' + addon + '" ' + direction; const base = { id:'itemId', generalid:'generalId', createtime:'createTime', updatedat:'updatedAt', title:'title' }[requested.toLowerCase()]; return base ? 'c."' + base + '" ' + direction : 'c."updatedAt" DESC';
  302. }
  303. async function contentPage(input, publicOnly = false) {
  304. const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['c."company" = $1'];
  305. if (input.objectId) { values.push(String(input.objectId)); clauses.push('c."objectId" = $' + values.length); }
  306. if (input.generalId) { values.push(String(input.generalId)); clauses.push('CAST(c."generalId" AS text) = $' + values.length); }
  307. const nodeIds = String(input.nodeid || input.nid || input.nodes || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0');
  308. if (nodeIds.length) { values.push(nodeIds); clauses.push('CAST(c."nodeId" AS text) = ANY($' + values.length + '::text[])'); }
  309. const modelId = contentModel(input, nodeIds); const config = CONTENT_ADDONS[modelId] || null; if (modelId) { values.push(String(modelId)); clauses.push('CAST(c."modelId" AS text) = $' + values.length); }
  310. if (publicOnly && !modelId) { values.push([...PRIVATE_CONTENT_MODELS].map(String)); clauses.push('CAST(c."modelId" AS text) <> ALL($' + values.length + '::text[])'); }
  311. const filters = legacyFilterPairs(input); if (filters.length && !config) fail(501, '该内容模型的 addon 联表尚未映射');
  312. for (const filter of filters) { const field = addonField(config, filter.name); if (!field) fail(501, 'addon 字段尚未映射: ' + filter.name); values.push(String(filter.value)); clauses.push('CAST(a."' + field + '" AS text) = $' + values.length); }
  313. if (input.skey) { values.push('%' + String(input.skey).replace(/[%_]/g, '') + '%'); clauses.push('c."title" ILIKE $' + values.length); }
  314. const join = config ? ' LEFT JOIN "' + config.className + '" a ON a."company" = $1 AND CAST(a."id" AS text) = CAST(c."itemId" AS text)' : ''; const where = clauses.join(' AND ');
  315. const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c' + join + ' WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
  316. const select = config ? 'c.*, row_to_json(a) AS "__addon"' : 'c.*'; const rows = await Psql.query('SELECT ' + select + ' FROM "CommonModel" c' + join + ' WHERE ' + where + ' ORDER BY ' + contentOrder(input, config) + ' LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues);
  317. const normalized = rows.filter(isVisible).map((source) => { const addon = source.__addon || {}; const row = { ...source, ...addon }; delete row.__addon; if (source.objectId) row.objectId = source.objectId; return legacyAliases(row, 'CommonModel'); }); const total = number(countRow.total);
  318. return { rows: normalized, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
  319. }
  320. async function sqlPage(className, input, filters = []) {
  321. if (!['App','Node','Product'].includes(className)) fail(400, '不允许查询该类'); const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['"company" = $1'];
  322. for (const filter of filters) { values.push(String(filter.value)); clauses.push('CAST("' + filter.field + '" AS text) = $' + values.length); }
  323. const where = clauses.join(' AND '); const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "' + className + '" WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
  324. const rows = await Psql.query('SELECT * FROM "' + className + '" WHERE ' + where + ' ORDER BY "updatedAt" DESC LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues); const total = number(countRow.total);
  325. return { rows: rows.filter(isVisible).map((row) => legacyAliases(row, className)), page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
  326. }
  327. function parseArray(value) { if (Array.isArray(value)) return value; if (typeof value !== 'string' || !value.trim()) return []; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch (_) { return []; } }
  328. function largePageInput(input) { return { index: Math.max(1, number(input.cpage || input.page, 1)), size: Math.min(500, Math.max(1, number(input.psize || input.pageSize, 20))) }; }
  329. function ownLegacyId(current) { return number(current && (current.get('legacyUserId') || current.get('userid') || current.get('num'))); }
  330. async function authorizeRequestedUser(current, requestedId) {
  331. const targetId = number(requestedId || ownLegacyId(current)); if (!targetId) fail(400, '缺少用户 ID'); if (isAdminUser(current) || targetId === ownLegacyId(current)) return targetId;
  332. const fields = await fieldsOf('_User'); const target = await findByLegacyId('_User', fields, targetId, ['legacyUserId','userid','num']); const agent = target && target.get('agent'); if (!target || !agent || agent.id !== current.id) fail(403, '无权查看该学员的业务记录'); return targetId;
  333. }
  334. async function authorizeBusinessRow(current, row, subjectFields, actorFields = []) {
  335. if (isAdminUser(current)) return; const ownId = ownLegacyId(current); const allIds = subjectFields.concat(actorFields).map((name) => number(row[name] ?? row[name.toLowerCase()] ?? row[name.toUpperCase()])).filter(Boolean); if (allIds.includes(ownId)) return;
  336. const subjectIds = subjectFields.map((name) => number(row[name] ?? row[name.toLowerCase()] ?? row[name.toUpperCase()])).filter(Boolean); if (!subjectIds.length) fail(403, '业务记录缺少可验证的所属用户'); const fields = await fieldsOf('_User'); for (const targetId of [...new Set(subjectIds)]) { const target = await findByLegacyId('_User', fields, targetId, ['legacyUserId','userid','num']); const agent = target && target.get('agent'); if (target && agent && agent.id === current.id) return; } fail(403, '无权查看该业务记录');
  337. }
  338. function normalizeWordRow(source) { const addon = source.__addon || {}; const merged = { ...source, ...addon }; delete merged.__addon; return legacyAliases(merged, 'CommonModel'); }
  339. async function orderDetailRow(inputId, current, authorize = true) {
  340. const id = String(inputId || ''); if (!id) fail(400, '缺少预约 ID'); const rows = await Psql.query('SELECT c.*, row_to_json(a) AS "__addon", n."nodeName" AS "__courseTitle" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company" = $1 AND CAST(a."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(a."kcid" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'54\' AND CAST(c."generalId" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]);
  341. let source = rows[0]; if (!source && /^[A-Za-z0-9_-]{10,40}$/.test(id)) { const direct = await Psql.query('SELECT a.*, n."nodeName" AS "__courseTitle" FROM "CourseAppointment" a LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(a."kcid" AS text) WHERE a."company" = $1 AND a."objectId" = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]); if (direct[0]) source = { ...direct[0], generalId: id, GeneralID: id, title: direct[0].title || '课程预约' }; }
  342. if (!source) return null; const addon = source.__addon || {}; const courseTitle = source.__courseTitle || source.subtitle || ''; delete source.__addon; delete source.__courseTitle; const row = legacyAliases({ ...source, ...addon }, 'CommonModel'); row.kcmc = row.kcmc || courseTitle; row.yykcmc = row.yykcmc || courseTitle; row.HoneyName = row.HoneyName || String(row.Title || '').split('(')[0]; if (authorize) await authorizeBusinessRow(current, row, ['szyh'], ['pl']); return row;
  343. }
  344. async function recordDetailData(input, current) {
  345. const id = String(input.id || input.gid || ''); if (!id) fail(400, '缺少学习记录 ID'); const rows = await Psql.query('SELECT c.*, row_to_json(d) AS "__addon" FROM "CommonModel" c JOIN "DailyStudyRecord" d ON d."company" = $1 AND CAST(d."id" AS text) = CAST(c."itemId" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'56\' AND CAST(c."generalId" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]);
  346. if (!rows.length) return null; const source = rows[0]; const recordAddon = source.__addon || {}; delete source.__addon; const record = legacyAliases({ ...source, ...recordAddon }, 'CommonModel'); await authorizeBusinessRow(current, record, ['userId'], ['pl']); const storedWords = parseArray(recordAddon.xxqs); const ids = [...new Set(storedWords.map((item) => String(item && (item.GeneralID ?? item.generalId ?? item.id) || '')).filter(Boolean))]; let wordRows = [];
  347. if (ids.length) wordRows = await Psql.query('SELECT c.*, row_to_json(v) AS "__addon" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."generalId" AS text) = ANY($2::text[])', [DEFAULT_COMPANY_ID, ids]);
  348. const details = new Map(wordRows.map((row) => { const normalized = normalizeWordRow(row); return [String(normalized.GeneralID || normalized.generalId), normalized]; })); const words = storedWords.map((item) => { const raw = item && typeof item === 'object' ? safe(item) : {}; const wordId = String(raw.GeneralID ?? raw.generalId ?? raw.id ?? ''); const detail = details.get(wordId) || legacyAliases({ generalId: wordId, title: raw.Title || raw.title || '' }, 'CommonModel'); return { ...detail, ...raw, GeneralID: number(wordId, wordId), Title: raw.Title || raw.title || detail.Title || detail.title || '', detail: [detail] }; });
  349. const order = recordAddon.dsid ? await orderDetailRow(recordAddon.dsid, current, false) : null; return { words, addon: order ? [order] : [], record };
  350. }
  351. async function newWordPage(input, current) {
  352. const uid = await authorizeRequestedUser(current, input.uid); const paging = largePageInput(input); const values = [DEFAULT_COMPANY_ID, String(uid)]; const where = 'p."company" = $1 AND CAST(p."yhid" AS text) = $2 AND LOWER(CAST(p."jrscb" AS text)) IN (\'1\',\'true\')'; const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "PracticeRecord" p WHERE ' + where, values); const rows = await Psql.query('SELECT p.*, pc."generalId" AS "__practiceGeneralId", c.*, row_to_json(v) AS "__addon" FROM "PracticeRecord" p JOIN "CommonModel" c ON c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."generalId" AS text) = CAST(p."scid" AS text) LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "CommonModel" pc ON pc."company" = $1 AND CAST(pc."modelId" AS text) = \'53\' AND CAST(pc."itemId" AS text) = CAST(p."id" AS text) WHERE ' + where + ' ORDER BY p."updatedAt" DESC LIMIT $3 OFFSET $4', values.concat([paging.size, (paging.index - 1) * paging.size]));
  353. const result = rows.map((source) => { const practice = { id: source.id, kcid: source.kcid, scid: source.scid, xxcs: source.xxcs, yhid: source.yhid, jrscb: source.jrscb }; const detail = normalizeWordRow(source); return { ...practice, GeneralID: number(source.__practiceGeneralId || source.id), Title: detail.Title || detail.title || '', detail: [detail] }; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
  354. }
  355. async function courseWordPage(input, current) {
  356. const uid = await authorizeRequestedUser(current, input.uid); const nodes = String(input.nids || input.nid || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0'); if (!nodes.length) fail(400, '缺少词库单元 ID'); const paging = { index: Math.max(1, number(input.cpage || input.page, 1)), size: Math.min(10000, Math.max(1, number(input.psize || input.pageSize, 1000))) }; const values = [DEFAULT_COMPANY_ID, String(uid), nodes]; const where = 'c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."nodeId" AS text) = ANY($3::text[])'; const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c WHERE ' + where, values); const rows = await Psql.query('SELECT c.*, row_to_json(v) AS "__addon", p."xxcs" AS "__learned", p."jrscb" AS "__newWord" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN LATERAL (SELECT pr."xxcs", pr."jrscb" FROM "PracticeRecord" pr WHERE pr."company" = $1 AND CAST(pr."yhid" AS text) = $2 AND CAST(pr."scid" AS text) = CAST(c."generalId" AS text) ORDER BY pr."updatedAt" DESC LIMIT 1) p ON TRUE WHERE ' + where + ' ORDER BY c."orderId" ASC, c."generalId" ASC LIMIT $4 OFFSET $5', values.concat([paging.size, (paging.index - 1) * paging.size])); const result = rows.map((source) => { const learned = number(source.__learned); const newWord = number(source.__newWord); delete source.__learned; delete source.__newWord; const word = normalizeWordRow(source); return { ...word, detail: word, gldy: word.GeneralID, w_learned: learned, ifnew: newWord }; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
  357. }
  358. async function updateLearningWords(input, current) {
  359. const uid = await authorizeRequestedUser(current, input.uid); const wordIds = [...new Set(String(input.wordsId || input.wordIds || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0'))]; if (!wordIds.length) fail(400, '请选择单词'); if (wordIds.length > 100) fail(400, '单次最多操作 100 个单词'); const saveLearning = number(input.save) === 1; const hasNewWordFlag = input.ifnew !== undefined && input.ifnew !== ''; const newWordFlag = hasNewWordFlag ? (number(input.ifnew) === 1 ? '1' : '0') : null; if (!saveLearning && !hasNewWordFlag) fail(400, '缺少学习或生词操作类型'); const kcid = number(input.kcid);
  360. const validRows = await Psql.query('SELECT CAST("generalId" AS text) AS id FROM "CommonModel" WHERE "company" = $1 AND CAST("modelId" AS text) = \'52\' AND CAST("generalId" AS text) = ANY($2::text[])', [DEFAULT_COMPANY_ID, wordIds]); const validIds = new Set(validRows.map((row) => String(row.id))); const invalid = wordIds.filter((id) => !validIds.has(id)); if (invalid.length) fail(400, '包含不存在的词库 ID: ' + invalid.join(','));
  361. const existingRows = await Psql.query('SELECT "objectId", CAST("scid" AS text) AS "scid" FROM "PracticeRecord" WHERE "company" = $1 AND CAST("yhid" AS text) = $2 AND CAST("scid" AS text) = ANY($3::text[])', [DEFAULT_COMPANY_ID, String(uid), wordIds]); const existing = await Promise.all(existingRows.map((row) => new Parse.Query('PracticeRecord').get(row.objectId, { useMasterKey: true }))); const byWord = new Map(existing.map((item) => [String(item.get('scid')), item])); const changed = []; let created = 0;
  362. for (const wordId of wordIds) { let item = byWord.get(wordId); if (!item) { if (!kcid) fail(400, '首次记录单词时缺少课程 ID'); item = new Parse.Object('PracticeRecord'); item.set('company', current.get('company')); item.set('sourceKey', 'cloud:e_add_words:' + uid + ':' + wordId); item.set('yhid', uid); item.set('scid', number(wordId)); item.set('kcid', String(kcid)); item.set('xxcs', 0); item.set('jrscb', '0'); created += 1; } else if (kcid && !item.get('kcid')) item.set('kcid', String(kcid)); if (saveLearning) item.set('xxcs', number(item.get('xxcs')) + 1); if (newWordFlag !== null) item.set('jrscb', newWordFlag); changed.push(item); }
  363. const saved = await Parse.Object.saveAll(changed, { useMasterKey: true }); return { affected: saved.length, created, learnedIncremented: saveLearning ? saved.length : 0, newWordState: newWordFlag === null ? undefined : number(newWordFlag), objectIds: saved.map((item) => item.id) };
  364. }
  365. function parseObject(value, label) { if (value && typeof value === 'object' && !Array.isArray(value)) return value; if (typeof value !== 'string' || !value.trim()) return {}; try { const parsed = JSON.parse(value); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; } catch (_) {} fail(400, label + '格式错误'); }
  366. function cleanText(value, max) { return String(value == null ? '' : value).trim().slice(0, max); }
  367. async function validateStudyWords(raw) { const words = parseArray(raw); if (words.length > 500) fail(400, '单次学习记录最多 500 个单词'); const normalized = words.map((item) => ({ GeneralID: number(item && (item.GeneralID ?? item.generalId ?? item.id)), Title: cleanText(item && (item.Title ?? item.title), 200), check: number(item && item.check) })).filter((item) => item.GeneralID); if (!normalized.length && words.length) fail(400, '学习记录中的单词 ID 无效'); if (normalized.length) { const ids = [...new Set(normalized.map((item) => String(item.GeneralID)))]; const rows = await Psql.query('SELECT CAST("generalId" AS text) AS id FROM "CommonModel" WHERE "company" = $1 AND CAST("modelId" AS text) = \'52\' AND CAST("generalId" AS text) = ANY($2::text[])', [DEFAULT_COMPANY_ID, ids]); if (new Set(rows.map((row) => String(row.id))).size !== ids.length) fail(400, '学习记录包含不存在的词库 ID'); } return normalized; }
  368. async function upsertStudyRecord(input, current) {
  369. const uid = await authorizeRequestedUser(current, input.uid); const orderId = String(input.orderId || input.id || '').trim(); if (!orderId) fail(400, '缺少预约 ID'); const order = await orderDetailRow(orderId, current); if (!order) fail(404, '预约记录不存在'); const orderOwner = number(order.szyh); if (orderOwner && orderOwner !== uid) fail(403, '预约记录与学员不匹配'); const addon = parseObject(input.addon, '学习记录'); const wordsProvided = addon.xxqs !== undefined; const words = wordsProvided ? await validateStudyWords(addon.xxqs) : null; const inputer = cleanText(input.inputer || current.get('nickname') || current.get('realName') || current.get('username'), 100); const title = cleanText(input.title || inputer, 200) || '学习记录';
  370. const rows = await Psql.query('SELECT c."objectId" AS "commonObjectId", c."generalId", d."objectId" AS "recordObjectId" FROM "CommonModel" c JOIN "DailyStudyRecord" d ON d."company" = $1 AND CAST(d."id" AS text) = CAST(c."itemId" AS text) WHERE c."company" = $1 AND CAST(c."modelId" AS text) = \'56\' AND CAST(d."dsid" AS text) = $2 AND CAST(d."userId" AS text) = $3 ORDER BY c."updatedAt" DESC LIMIT 1', [DEFAULT_COMPANY_ID, orderId, String(uid)]); const found = rows[0] || null; let record; let common; let created = false;
  371. if (found) { record = await new Parse.Query('DailyStudyRecord').get(found.recordObjectId, { useMasterKey: true }); common = await new Parse.Query('CommonModel').get(found.commonObjectId, { useMasterKey: true }); }
  372. const applyFields = (target) => { const actorId = ownLegacyId(current); target.set('userId', uid); target.set('dsid', orderId); target.set('pl', actorId && actorId !== uid ? String(actorId) : '0'); if (addon.con !== undefined) target.set('con', cleanText(addon.con, 1000)); if (addon.dqrq !== undefined) { const date = cleanText(addon.dqrq, 20); if (!/^\d{8}$/.test(date)) fail(400, '学习日期必须为 yyyymmdd'); target.set('dqrq', date); } if (addon.szmdid !== undefined) target.set('szmdid', cleanText(addon.szmdid, 100)); if (addon.fxrl !== undefined) target.set('fxrl', cleanText(addon.fxrl, 1000)); if (wordsProvided) { const learned = words.length; target.set('xxqs', JSON.stringify(words)); target.set('learned', learned); target.set('ygg', words.filter((item) => item.check === 1).length); target.set('djq', words.filter((item) => item.check === 2).length); } else { for (const name of ['learned','ygg','djq']) if (addon[name] !== undefined) target.set(name, Math.max(0, number(addon[name]))); } };
  373. if (record) { applyFields(record); common.set('title', title); common.set('inputer', inputer); common.set('hits', number(record.get('learned'))); await Parse.Object.saveAll([record, common], { useMasterKey: true }); return { GeneralID: number(found.generalId), generalId: number(found.generalId), objectId: common.id, recordObjectId: record.id, created: false }; }
  374. created = true; const baseId = Math.floor(100000000000000 + Math.random() * 800000000000000); const recordId = baseId; const generalId = baseId + 1; record = new Parse.Object('DailyStudyRecord'); record.set('company', current.get('company')); record.set('sourceKey', 'cloud:stu_record:' + uid + ':' + orderId); applyFields(record); await record.save(null, { useMasterKey: true }); try { await Psql.none('UPDATE "DailyStudyRecord" SET "id" = $1 WHERE "objectId" = $2', [recordId, record.id]); common = new Parse.Object('CommonModel'); common.set('company', current.get('company')); common.set('sourceKey', 'cloud:stu_record:' + uid + ':' + orderId); common.set('generalId', generalId); common.set('itemId', recordId); common.set('modelId', 56); common.set('nodeId', 291); common.set('tableName', 'ZL_C_ss'); common.set('title', title); common.set('inputer', inputer); common.set('hits', number(record.get('learned'))); common.set('status', 99); await common.save(null, { useMasterKey: true }); } catch (error) { if (common && common.id) await common.destroy({ useMasterKey: true }).catch(() => undefined); await record.destroy({ useMasterKey: true }).catch(() => undefined); throw error; } return { GeneralID: generalId, generalId, objectId: common.id, recordObjectId: record.id, created };
  375. }
  376. async function orderStatistics(input, current) {
  377. const uid = await authorizeRequestedUser(current, input.uid); let target = current; if (uid !== ownLegacyId(current)) { const fields = await fieldsOf('_User'); target = await findByLegacyId('_User', fields, uid, ['legacyUserId','userid','num']); } if (!target) fail(404, '用户不存在'); const groupId = number(target.get('legacyGroupId') || target.get('groupId')); let t30 = 0; let t60 = 0; let tTiyan = 0; let total = 0; let commission = 0; let duration = 0;
  378. if (groupId === 3) { const row = await Psql.one('SELECT COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'1\')::int AS t30, COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'2\')::int AS t60, COUNT(*) FILTER (WHERE CAST("kclx" AS text) = \'3\')::int AS tiyan, COUNT(*)::int AS total FROM "LessonRecord" WHERE "company" = $1 AND CAST("jsmz" AS text) = $2', [DEFAULT_COMPANY_ID, String(uid)]); t30 = number(row.t30); t60 = number(row.t60); tTiyan = number(row.tiyan); total = number(row.total); commission = t30 * 20 + t60 * 40 + tTiyan * 40; duration = t30 * 0.5 + t60 + tTiyan; }
  379. else if (groupId === 1) { const row = await Psql.one('SELECT COUNT(*) FILTER (WHERE CAST("dslx" AS text) = \'1\')::int AS t30, COUNT(*) FILTER (WHERE CAST("dslx" AS text) = \'2\')::int AS t60, COUNT(*) FILTER (WHERE CAST("dslx" AS text) = \'3\')::int AS tiyan FROM "CourseAppointment" WHERE "company" = $1 AND CAST("szyh" AS text) = $2 AND CAST("dszt" AS text) ~ \'^[0-9]+$\' AND CAST("dszt" AS numeric) > 10', [DEFAULT_COMPANY_ID, String(uid)]); const practice = await Psql.one('SELECT COUNT(*)::int AS total FROM "PracticeRecord" WHERE "company" = $1 AND CAST("yhid" AS text) = $2', [DEFAULT_COMPANY_ID, String(uid)]); t30 = number(row.t30); t60 = number(row.t60); tTiyan = number(row.tiyan); total = number(practice.total); duration = t30 * 0.5 + t60 + tTiyan; }
  380. return { t30:String(t30), t60:String(t60), t_tiyan:String(tTiyan), t_shichang:String(duration), t_total:String(total), t_yongji:String(commission) };
  381. }
  382. function learnedDate(row) { const raw = String(row.dqrq || '').trim(); return /^\d{8}$/.test(raw) ? raw.slice(0,4) + '-' + raw.slice(4,6) + '-' + raw.slice(6,8) : String(row.kywsj || '').slice(0,10); }
  383. async function memoryPage(input, current) {
  384. const uid = await authorizeRequestedUser(current, input.uid); const paging = largePageInput(input); const income = number(input.shouru) === 1; const values = [DEFAULT_COMPANY_ID, String(uid)]; const clauses = ['c."company" = $1', 'CAST(c."modelId" AS text) = \'60\'', 'CAST(m."' + (income ? 'plid' : 'yhid') + '" AS text) = $2']; if (input.status !== undefined && input.status !== '') { values.push(String(number(input.status))); clauses.push('CAST(m."fxzt" AS text) = $' + values.length); } const where = clauses.join(' AND '); const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m."company" = $1 AND CAST(m."id" AS text) = CAST(c."itemId" AS text) WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
  385. const rows = await Psql.query('SELECT c.*, row_to_json(m) AS "__addon", d."learned", d."dqrq", n."nodeName" AS "kc_title" FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m."company" = $1 AND CAST(m."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN "CommonModel" dc ON dc."company" = $1 AND CAST(dc."modelId" AS text) = \'56\' AND CAST(dc."generalId" AS text) = CAST(m."xxjlid" AS text) LEFT JOIN "DailyStudyRecord" d ON d."company" = $1 AND CAST(d."id" AS text) = CAST(dc."itemId" AS text) LEFT JOIN "Node" n ON n."company" = $1 AND CAST(n."nodeId" AS text) = CAST(m."kcid" AS text) WHERE ' + where + ' ORDER BY m."kywsj" DESC, c."updatedAt" DESC LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues); const result = rows.map((source) => { const addon = source.__addon || {}; delete source.__addon; const row = legacyAliases({ ...source, ...addon }, 'CommonModel'); row.learned_date = learnedDate(row); if (income) { row.money = 0; row.incomeRuleUnavailable = true; } return row; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
  386. }
  387. async function resolveContent(input, requireOne = false) { const fields = await fieldsOf('CommonModel'); const query = new Parse.Query('CommonModel'); tenant(query, fields); const id = String(input.id || input.gid || input.generalId || ''); if (!id) { if (requireOne) fail(400, '缺少内容 ID'); return { query, fields }; } if (/^[A-Za-z0-9_-]{10,40}$/.test(id)) { try { return { object: await query.get(id, { useMasterKey: true }), fields }; } catch (_) {} } query.equalTo('generalId', number(id)); return { object: await query.first({ useMasterKey: true }), fields }; }
  388. async function handler(request, response) {
  389. try {
  390. const input = inputOf(request); const action = String(input.action || ''); if (!action) return response.status(400).json(reject('缺少 action'));
  391. if (action === 'migration_status') return response.json(envelope({ implemented: ['app_update','user_login_passwd','user_register','user_info_name','user_get','user_list','e_user_list','user_update','user_update_pwd','user_update_pwdall','content_list','content_list_llk','content_get','content_uphis','node_list','node_get','product_list','product_get','product_stock_list','e_add_words','e_ck_list','e_get_21list','e_order_detail','e_order_tongji','e_record_detail','e_words_list','stu_record_update_v2','user_point_list','guestbook_add'], blocked: BLOCKED }));
  392. if (BLOCKED[action]) return response.status(501).json(reject('migration_blocked: ' + BLOCKED[action]));
  393. if (action === 'user_login_passwd') {
  394. const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username || !password) return response.status(400).json(reject('请输入账号和密码'));
  395. try { const loggedIn = await Parse.User.logIn(username, password); if (loggedIn.get('passwordResetRequired') === true) return response.status(403).json(reject('旧系统账号必须先重置 Parse 密码')); return response.json(envelope(legacyUser(loggedIn, true), { State: loggedIn.get('isDisabled') === true ? 0 : 1 })); } catch (_) { return response.status(401).json(reject('账号或密码错误')); }
  396. }
  397. if (action === 'user_register') {
  398. const username = String(input.name || '').trim(); const password = String(input.passwd || ''); if (!username || password.length < 8) return response.status(400).json(reject('账号不能为空,密码至少 8 位'));
  399. const created = new Parse.User(); created.setUsername(username); created.setPassword(password); created.set('company', companyPointer()); created.set('type','user'); created.set('isDisabled', false); await created.signUp(); return response.json(envelope(legacyUser(created, true), { State: 1 }));
  400. }
  401. if (action === 'user_info_name') { const username = String(input.uname || input.name || '').trim(); if (!username) return response.status(400).json(reject('缺少用户名')); const rows = await Psql.query('SELECT "objectId" FROM "_User" WHERE "company" = $1 AND "username" = $2 LIMIT 1', [DEFAULT_COMPANY_ID, username]); const found = rows[0]; return found ? response.json(envelope({ objectId: found.objectId })) : response.status(404).json(reject('用户不存在')); }
  402. const publicRead = PUBLIC_READ.has(action) && !contentQueryRequiresAuth(action, input); const current = await currentUser(request, !publicRead); if (contentQueryRequiresAuth(action, input) && current) await authorizeContentAccess(current, input);
  403. if (action === 'user_get') { const requestedId = number(input.uid || current.id); let target = current; const ownId = number(current.get('legacyUserId') || current.get('userid') || current.get('num')); if (requestedId && requestedId !== ownId) { const fields = await fieldsOf('_User'); const found = await findByLegacyId('_User', fields, requestedId, ['legacyUserId','userid','num']); if (!found) return response.status(404).json(reject('用户不存在')); const isAdmin = current.get('isAdmin') === true; const isAgentChild = found.get('agent') && found.get('agent').id === current.id; if (!isAdmin && !isAgentChild) return response.status(403).json(reject('无权查看该用户')); target = found; } return response.json(envelope(legacyUser(target), { State: target.get('isDisabled') === true ? 0 : 1 })); }
  404. if (action === 'user_list' || action === 'e_user_list') { if (action === 'user_list' && current.get('isAdmin') !== true) return response.status(403).json(reject('仅管理员可查询全量用户')); const result = await paged('_User', input, (query, fields) => { const uid = number(current.get('legacyUserId') || current.get('userid')); if (action === 'e_user_list' && fields.agent) query.equalTo('agent', current); else if (fields.legacyUserId && uid) query.notEqualTo('legacyUserId', uid); }); return response.json(envelope(result.rows.map((row) => ({ ...row, userId: number(row.legacyUserId || row.userid || row.num), userName: row.username, honeyName: row.nickname || row.realName || row.username })), undefined, result.page)); }
  405. if (action === 'user_update' || action === 'user_update_pwd' || action === 'user_update_pwdall') { if (action !== 'user_update') { const password = String(input.passwd || input.password || input.pwd || ''); if (password.length < 8) return response.status(400).json(reject('新密码至少 8 位')); current.setPassword(password); current.set('passwordResetRequired', false); } else { const allowed = { honeyName:'nickname', nickname:'nickname', mobile:'mobile', userFace:'avatar', avatar:'avatar', realName:'realName', gender:'gender', school:'school' }; for (const [source,target] of Object.entries(allowed)) if (input[source] !== undefined) current.set(target, input[source]); } await current.save(null, { useMasterKey: true }); return response.json(envelope(legacyUser(current, action !== 'user_update'))); }
  406. if (action === 'app_update') { const filters = input.platform ? [{ field: 'platform', value: input.platform }] : []; const result = await sqlPage('App', { page: 1, pageSize: 1 }, filters); return response.json(envelope(result.rows[0] || null)); }
  407. if (action === 'content_list' || action === 'content_list_llk') { const result = await contentPage(input, !current); return response.json(envelope(result.rows, undefined, result.page)); }
  408. if (action === 'content_get') { const id = String(input.id || input.gid || input.generalId || ''); if (!id) return response.status(400).json(reject('缺少内容 ID')); const identity = /^[A-Za-z0-9_-]{10,40}$/.test(id) ? { objectId: id } : { generalId: id }; const base = await contentPage({ page: 1, pageSize: 1, ...identity }); let detail = base.rows[0]; if (!detail) return response.status(404).json(reject('内容不存在')); const detailModel = number(detail.modelId || detail.ModelID); if (CONTENT_ADDONS[detailModel]) detail = (await contentPage({ page: 1, pageSize: 1, modelId: detailModel, ...identity })).rows[0] || detail; if (PRIVATE_CONTENT_MODELS.has(detailModel)) { if (!current) return response.status(401).json(reject('该内容详情需要登录')); await authorizeContentDetail(current, detail); } return response.json(envelope(detail)); }
  409. if (action === 'content_uphis') { const resolved = await resolveContent(input, true); if (!resolved.object) return response.status(404).json(reject('内容不存在')); resolved.object.increment('hits', Math.max(1, number(input.num, 1))); await resolved.object.save(null, { useMasterKey: true }); return response.json(envelope({ hits: resolved.object.get('hits') })); }
  410. if (action === 'content_add' || action === 'content_update') { if (!current) fail(401,'需要登录'); const resolved = action === 'content_update' ? await resolveContent(input, true) : { object: new Parse.Object('CommonModel'), fields: await fieldsOf('CommonModel') }; const object = resolved.object; if (!object) return response.status(404).json(reject('内容不存在')); const allowed = ['title','subtitle','nodeId','modelId','status','topImg','tagKey','template']; for (const name of allowed) if (input[name] !== undefined && resolved.fields[name]) object.set(name, ['nodeId','modelId','status'].includes(name) ? number(input[name]) : input[name]); object.set('company', companyPointer()); object.set('inputer', String(current.get('username') || '')); await object.save(null, { useMasterKey: true }); return response.json(envelope(safe(object))); }
  411. if (action === 'node_list') { const filters = input.pid !== undefined ? [{ field: 'parentId', value: number(input.pid) }] : []; const result = await sqlPage('Node', input, filters); return response.json(envelope(result.rows, undefined, result.page)); }
  412. if (action === 'node_get') { const result = await sqlPage('Node', { page: 1, pageSize: 1 }, [{ field: 'nodeId', value: number(input.id || input.nid) }]); return result.rows[0] ? response.json(envelope(result.rows[0])) : response.status(404).json(reject('栏目不存在')); }
  413. if (action === 'product_list' || action === 'product_stock_list') { const result = await sqlPage('Product', input); return response.json(envelope(result.rows, undefined, result.page)); }
  414. if (action === 'product_get') { const result = await sqlPage('Product', { page: 1, pageSize: 1 }, [{ field: 'id', value: number(input.id) }]); return result.rows[0] ? response.json(envelope(result.rows[0])) : response.status(404).json(reject('商品不存在')); }
  415. if (action === 'product_del' || action === 'product_stock_change' || action === 'product_sale_change') { const fields = await fieldsOf('Product'); const found = await findByLegacyId('Product', fields, input.id, ['id']); if (!found) return response.status(404).json(reject('商品不存在')); if (action === 'product_del') await found.destroy({ useMasterKey: true }); else { const field = action === 'product_stock_change' ? 'nums' : 'isChu'; if (fields[field]) found.set(field, action === 'product_stock_change' ? number(input.nums || input.stock) : Boolean(input.value ?? input.status)); await found.save(null, { useMasterKey: true }); } return response.json(envelope({ objectId: found.id })); }
  416. const legacyId = ownLegacyId(current);
  417. if (action === 'e_order_tongji') return response.json(envelope(await orderStatistics(input, current)));
  418. if (action === 'stu_record_update_v2') return response.json(envelope(await upsertStudyRecord(input, current)));
  419. if (action === 'e_add_words') return response.json(envelope(await updateLearningWords(input, current)));
  420. if (action === 'e_ck_list') { const result = await courseWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
  421. if (action === 'e_get_21list') { const result = await memoryPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
  422. if (action === 'e_words_list') { const result = await newWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
  423. if (action === 'e_order_detail') { const found = await orderDetailRow(input.id, current); return found ? response.json(envelope([found])) : response.status(404).json(reject('预约记录不存在')); }
  424. if (action === 'e_record_detail') { const found = await recordDetailData(input, current); return found ? response.json(envelope(found.words, found.addon)) : response.status(404).json(reject('学习记录不存在')); }
  425. const listMappings = { user_point_list:['UserUserPoint','userId'] };
  426. if (listMappings[action]) { const [className,userField] = listMappings[action]; const result = await paged(className, input, (query, fields) => { if (userField && fields[userField]) query.equalTo(userField, legacyId); if (input.kcid && fields.kcid) query.equalTo('kcid', number(input.kcid)); }); return response.json(envelope(result.rows, undefined, result.page)); }
  427. if (action === 'guestbook_add') { const fields = await fieldsOf('Guestbook'); const item = new Parse.Object('Guestbook'); item.set('company',companyPointer()); if (fields.userid) item.set('userid',legacyId); if (fields.title) item.set('title',String(input.title || '用户反馈')); if (fields.tcontent) item.set('tcontent',String(input.content || input.tcontent || '')); if (fields.status) item.set('status',0); await item.save(null,{useMasterKey:true}); return response.json(envelope(safe(item))); }
  428. return response.status(501).json(reject('migration_blocked: 尚未完成动作映射 ' + action));
  429. } catch (error) { const status = Number(error.status || (error.code === 101 ? 404 : 500)); return response.status(status).json(reject(status >= 500 ? '云函数执行失败' : error.message)); }
  430. }
  431. `;
  432. const definitions = [
  433. { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode, params: [{ name: 'operation', type: 'String', required: true }] },
  434. { 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 }] },
  435. { name: 'cms.users.extended', desc: '替代 ZL_EX_UserView,返回安全用户扩展资料', path: 'xiaoshu/cms/users/extended', code: cmsReadCode('user-extended'), params: [{ name: 'userId', type: 'String', required: true }] },
  436. { name: 'cms.users.platform', desc: '替代 ZL_User_PlatView', path: 'xiaoshu/cms/users/platform', code: cmsReadCode('user-platform'), params: [{ name: 'userId', type: 'String', required: true }] },
  437. { name: 'cms.users.wechat', desc: '替代 ZL_User_WXView,过滤令牌与密钥', path: 'xiaoshu/cms/users/wechat', code: cmsReadCode('user-wechat'), params: [{ name: 'userId', type: 'String', required: true }] },
  438. { 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 }] },
  439. { 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 }] },
  440. { name: 'cms.search', desc: '替代 ZL_SearchView,搜索规范化 CommonModel', path: 'xiaoshu/cms/search', code: cmsReadCode('search'), params: [{ name: 'keyword', type: 'String', required: true }] },
  441. { name: 'xiaoshu.app.gateway', desc: '旧 WXAPP action 到 Parse 规范化类的兼容云函数;无法证明等价的动作返回 migration_blocked', path: 'xiaoshu/app/gateway', code: appGatewayCode, params: [{ name: 'action', type: 'String', required: true }] },
  442. ];
  443. function validateDefinition(definition) {
  444. if (definition.path.startsWith('/')) throw new Error(`${definition.name}: 当前执行器的全局 path 不接受前导 /`);
  445. const factory = new Function(`${definition.code}\nreturn typeof handler;`);
  446. if (factory() !== 'function') throw new Error(`${definition.name}: 未定义 handler`);
  447. }
  448. async function parseRequest(path, init = {}) {
  449. const response = await fetch(`${PARSE_URL}${path}`, {
  450. ...init,
  451. headers: {
  452. 'X-Parse-Application-Id': APP_ID,
  453. 'X-Parse-Master-Key': MASTER_KEY,
  454. 'Content-Type': 'application/json',
  455. ...(init.headers || {}),
  456. },
  457. });
  458. const payload = await response.json();
  459. if (!response.ok || payload.error) throw new Error(payload.error || `Parse 请求失败:${response.status}`);
  460. return payload;
  461. }
  462. async function upsert(definition) {
  463. const where = encodeURIComponent(JSON.stringify({ name: definition.name }));
  464. const existing = await parseRequest(`/classes/Function?where=${where}&limit=1&keys=objectId`);
  465. const body = {
  466. name: definition.name,
  467. desc: definition.desc,
  468. type: 'standalone',
  469. path: definition.path,
  470. code: definition.code.trim(),
  471. params: definition.params,
  472. paramList: definition.params,
  473. respType: 'json',
  474. respJson: { success: true, data: {} },
  475. enabled: true,
  476. version: '1.1.2',
  477. };
  478. const objectId = existing.results?.[0]?.objectId;
  479. if (objectId) {
  480. await parseRequest(`/classes/Function/${objectId}`, { method: 'PUT', body: JSON.stringify(body) });
  481. return { action: 'updated', objectId };
  482. }
  483. const created = await parseRequest('/classes/Function', { method: 'POST', body: JSON.stringify(body) });
  484. return { action: 'created', objectId: created.objectId };
  485. }
  486. for (const definition of definitions) validateDefinition(definition);
  487. if (validateOnly) {
  488. console.log(`Validated ${definitions.length} cloud function definitions.`);
  489. } else {
  490. if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY;不会把 masterKey 写入项目文件。');
  491. for (const definition of definitions) {
  492. const result = await upsert(definition);
  493. console.log(`${result.action.padEnd(7)} ${definition.path} (${result.objectId})`);
  494. }
  495. }