deploy-admin-functions.mjs 164 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146
  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','isDisabled','legacyUserId','legacyGroupId','legacyUserData','groupId']);
  20. const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
  21. const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
  22. const CLASS_SYSTEM_FIELDS = {
  23. CommonModel: new Set(['generalId','modelId','nodeId','itemId','tableName','status','isDeleted','sourceKey','orderId']),
  24. Node: new Set(['nodeId','parentId','depth','child','orderId','zstatus','sourceKey','cuser','cuname','editDate']),
  25. Special: new Set(['specId','pid','orderId','sourceKey','cuser','editDate']),
  26. Model: new Set(['modelId','modelType','tableName','sourceKey','nodeId','fromModel','multiFlag','sysModel']),
  27. ModelField: new Set(['fieldId','modelId','fieldName','fieldType','sourceKey','sysType','orderId']),
  28. Guestbook: new Set(['gid','parentid','cateid','userid','status','sourceKey','gdate','ip']),
  29. Guestcate: new Set(['cateid','parentId','gtype','sourceKey','orderId'])
  30. };
  31. function inputOf(request) {
  32. const body = request.body || {};
  33. return body.params && typeof body.params === 'object' ? body.params : body;
  34. }
  35. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  36. function pointerId(value) { return value && (value.id || value.objectId || (value.__type === 'Pointer' && value.objectId)); }
  37. function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
  38. function safeValue(value, depth = 0) {
  39. if (value == null || depth > 4) return value;
  40. if (Array.isArray(value)) return value.map((item) => safeValue(item, depth + 1));
  41. if (value instanceof Date) return value.toISOString();
  42. if (value && typeof value.toJSON === 'function') return safeValue(value.toJSON(), depth + 1);
  43. if (typeof value === 'object') {
  44. const output = {};
  45. for (const [key, item] of Object.entries(value)) {
  46. if (HIDDEN_FIELDS.has(key) || /(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(key)) continue;
  47. output[key] = safeValue(item, depth + 1);
  48. }
  49. return output;
  50. }
  51. return value;
  52. }
  53. 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); }
  54. async function requireAdmin(request) {
  55. const current = request.user || (typeof user !== 'undefined' ? user : null);
  56. if (!current) fail(401, '管理员会话已失效');
  57. await current.fetch({ useMasterKey: true });
  58. if (current.get('isDisabled') === true || current.get('isDeleted') === true) fail(403, '管理员账号已停用');
  59. const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
  60. const role = String(current.get('role') || '');
  61. const roleKey = String(current.get('adminRoleKey') || '');
  62. const isSuperAdmin = roleKey === 'super-admin' || roles.includes('super-admin');
  63. const isAdmin = current.get('isAdmin') === true || role === 'admin' || roles.includes('admin') || isSuperAdmin;
  64. if (!isAdmin) fail(403, '当前账号未被授权为后台管理员');
  65. const company = request.company || current.get('company') || null;
  66. if (!company && !isSuperAdmin) fail(403, '管理员账号尚未分配帐套');
  67. return { current, roles, isSuperAdmin, company };
  68. }
  69. function isAdminAccount(target) {
  70. const roles = Array.isArray(target.get('roles')) ? target.get('roles').map(String) : [];
  71. return target.get('isAdmin') === true || target.get('role') === 'admin' || roles.includes('admin') || target.get('adminRoleKey') === 'super-admin' || roles.includes('super-admin');
  72. }
  73. function assertCanManageUser(context, target, action) {
  74. if (action === 'lock' && target.id === context.current.id) fail(400, '不能停用当前登录账号');
  75. if (isAdminAccount(target) && target.id !== context.current.id && !context.isSuperAdmin) fail(403, '只有超级管理员可以管理其他管理员账号');
  76. }
  77. async function revokeSessions(targets) {
  78. let revoked = 0;
  79. while (targets.length) {
  80. const query = new Parse.Query('_Session'); query.containedIn('user', targets); query.limit(1000);
  81. const sessions = await query.find({ useMasterKey: true });
  82. if (!sessions.length) break;
  83. await Parse.Object.destroyAll(sessions, { useMasterKey: true }); revoked += sessions.length;
  84. if (sessions.length < 1000) break;
  85. }
  86. return revoked;
  87. }
  88. function assertClass(className) {
  89. if (!ALLOWED_CLASSES.has(className)) fail(400, '不允许访问该数据类');
  90. }
  91. function isSystemField(className, name) { return SYSTEM_FIELDS.has(name) || Boolean(CLASS_SYSTEM_FIELDS[className] && CLASS_SYSTEM_FIELDS[className].has(name)); }
  92. async function schemaFor(className) {
  93. assertClass(className);
  94. const schema = await new Parse.Schema(className).get({ useMasterKey: true });
  95. return schema && schema.fields ? schema.fields : {};
  96. }
  97. function applyTenant(query, fields, context, requestedCompanyId) {
  98. if (!fields.company) return;
  99. if (context.isSuperAdmin && requestedCompanyId) {
  100. query.equalTo('company', Parse.Object.createWithoutData('Company', String(requestedCompanyId)));
  101. } else if (context.company) {
  102. query.equalTo('company', context.company);
  103. }
  104. }
  105. function serializeObject(object) { return safeValue(object.toJSON()); }
  106. function toParseValue(field, value) {
  107. if (value == null || value === '') return value;
  108. if (field.type === 'Pointer') {
  109. const objectId = pointerId(value) || value;
  110. return Parse.Object.createWithoutData(field.targetClass, String(objectId));
  111. }
  112. if (field.type === 'Date') {
  113. const date = new Date(value.iso || value);
  114. if (Number.isNaN(date.getTime())) fail(400, '日期字段格式无效');
  115. return date;
  116. }
  117. if (field.type === 'Number') {
  118. const number = Number(value);
  119. if (!Number.isFinite(number)) fail(400, '数字字段格式无效');
  120. return number;
  121. }
  122. if (field.type === 'Boolean') return value === true || value === 'true';
  123. if (field.type === 'Array') { if (!Array.isArray(value)) fail(400, '数组字段格式无效'); return value; }
  124. if (field.type === 'Object') { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(400, '对象字段格式无效'); return value; }
  125. if (!GENERIC_WRITE_TYPES.has(field.type)) fail(400, '该字段类型不允许通用编辑: ' + field.type);
  126. return value;
  127. }
  128. async function countClass(className, context) {
  129. const fields = await schemaFor(className);
  130. const query = new Parse.Query(className);
  131. applyTenant(query, fields, context);
  132. if (fields.isDeleted) query.notEqualTo('isDeleted', true);
  133. return query.count({ useMasterKey: true });
  134. }
  135. async function audit(context, action, className, objectId) {
  136. try {
  137. const log = new Parse.Object('SysLog');
  138. const schema = await new Parse.Schema('SysLog').get({ useMasterKey: true });
  139. const fields = schema.fields || {};
  140. if (fields.company && context.company) log.set('company', context.company);
  141. if (fields.userId) log.set('userId', context.current.id);
  142. if (fields.userName) log.set('userName', String(context.current.get('username') || ''));
  143. if (fields.remind) log.set('remind', '[AngularAdmin] ' + action + ' ' + className + '/' + objectId);
  144. if (fields.logType) log.set('logType', 'angular-admin');
  145. await log.save(null, { useMasterKey: true });
  146. } catch (_) { /* 审计表字段来自旧系统,失败不能覆盖主操作结果。 */ }
  147. }
  148. async function handler(request, response) {
  149. try {
  150. const input = inputOf(request);
  151. const operation = String(input.operation || 'meta');
  152. const context = await requireAdmin(request);
  153. if (operation === 'meta') {
  154. const functionQuery = new Parse.Query('Function');
  155. functionQuery.notEqualTo('enabled', false);
  156. const cloudFunctions = await functionQuery.count({ useMasterKey: true });
  157. return response.json({ success: true, data: {
  158. 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 },
  159. classes: ALLOWED_CLASSES.size, cloudFunctions
  160. }});
  161. }
  162. if (operation === 'dashboard') {
  163. const metrics = [
  164. ['_User','用户总数'],['CourseBinding','课程绑定'],['CourseAppointment','课程预约'],['PracticeRecord','练习记录'],['LessonRecord','上课记录'],['DailyStudyRecord','每日学习'],['VocabularyWord','词库单词'],['CommonModel','内容记录']
  165. ];
  166. const counts = await Promise.all(metrics.map(async ([className, label]) => ({ className, label, count: await countClass(className, context), route: '/admin/resources/' + className })));
  167. const functionQuery = new Parse.Query('Function'); functionQuery.notEqualTo('enabled', false);
  168. let registeredRecords = 195452;
  169. try { const row = await Psql.oneOrNone('SELECT COUNT(*)::int AS count FROM cms.record_registry'); if (row) registeredRecords = Number(row.count); } catch (_) {}
  170. 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 }) } } });
  171. }
  172. if (operation === 'catalog') {
  173. const resources = [...ALLOWED_CLASSES].sort().map((className) => ({ className, label: CLASS_LABELS[className] || className, writable: !READ_ONLY_CLASSES.has(className), route: '/admin/resources/' + className }));
  174. return response.json({ success: true, data: { total: resources.length, resources } });
  175. }
  176. if (operation === 'createUser') {
  177. const username = String(input.username || '').trim();
  178. const password = typeof input.password === 'string' ? input.password : '';
  179. const displayName = String(input.displayName || '').trim();
  180. const mobile = String(input.mobile || '').trim();
  181. const inviteUsername = String(input.inviteUsername || '').trim();
  182. if (username.length < 3 || username.length > 64) fail(400, '账号长度应为 3 至 64 位');
  183. if (password.length < 8 || password.length > 72) fail(400, '密码长度应为 8 至 72 位');
  184. if (displayName.length > 100) fail(400, '显示名不能超过 100 位');
  185. if (mobile.length > 32) fail(400, '手机号不能超过 32 位');
  186. let companyId = pointerId(context.company);
  187. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  188. if (!companyId) fail(400, '开户必须指定帐套');
  189. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  190. const userFields = await schemaFor('_User');
  191. if (!userFields.company) fail(500, '用户表缺少帐套字段');
  192. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "_User" WHERE "company"=$1 AND "username"=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1', [companyId, username]);
  193. if (duplicate) fail(409, '此用户名已被使用');
  194. let parentId = 0;
  195. if (inviteUsername) {
  196. const inviter = await Psql.oneOrNone('SELECT COALESCE("legacyUserId", CASE WHEN COALESCE("legacyUserData"->>\'UserID\',\'\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'UserID\')::numeric END) AS id FROM "_User" WHERE "company"=$1 AND "username"=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1', [companyId, inviteUsername]);
  197. parentId = Number(inviter && inviter.id) || 0;
  198. if (!parentId) fail(400, '邀请账号不存在');
  199. }
  200. const created = new Parse.User();
  201. created.setUsername(username); created.setPassword(password); created.set('company', company);
  202. if (userFields.type) created.set('type', 'user');
  203. if (userFields.isDisabled) created.set('isDisabled', false);
  204. if (userFields.isDeleted) created.set('isDeleted', false);
  205. if (userFields.legacyGroupId) created.set('legacyGroupId', 1);
  206. if (userFields.nickname) created.set('nickname', displayName || username);
  207. if (mobile && userFields.mobile) created.set('mobile', mobile);
  208. if (userFields.isAdmin) created.set('isAdmin', false);
  209. if (userFields.roles) created.set('roles', []);
  210. try { await created.signUp(null, { useMasterKey: true }); }
  211. catch (error) { if (Number(error && error.code) === 202) fail(409, '此用户名已被使用'); throw error; }
  212. try {
  213. const regTime = new Date().toISOString();
  214. const legacyData = { UserID:0,UserName:username,HoneyName:displayName || username,GroupID:1,ParentUserID:parentId,VIP:0,RegTime:regTime,Purse:0,SilverCoin:0,UserExp:0,UserPoint:0,boffExp:0,State:1 };
  215. const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-legacy-user-id\'))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("legacyUserId"),0)+1 AS id FROM "_User",lock_row WHERE "company"=$1 AND COALESCE("legacyUserId",0)>0) UPDATE "_User" SET "legacyUserId"=next_id.id,"legacyGroupId"=1,"legacyUserData"=jsonb_set($3::jsonb,\'{UserID}\',to_jsonb(next_id.id),true),"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, created.id, JSON.stringify(legacyData)]);
  216. const legacyUserId = Number(rows[0] && rows[0].id) || 0;
  217. if (!legacyUserId) throw new Error('无法分配旧系统用户 ID');
  218. await created.fetch({ useMasterKey: true });
  219. await audit({ ...context, company }, 'create-user', '_User', created.id);
  220. return response.json({ success: true, data: { ...serializeObject(created), userId: legacyUserId, groupId: 1 } });
  221. } catch (error) {
  222. await created.destroy({ useMasterKey: true }).catch(() => undefined);
  223. throw error;
  224. }
  225. }
  226. if (operation === 'userBatch') {
  227. const action = String(input.action || '');
  228. if (!['lock','unlock','move'].includes(action)) fail(400, '不支持的用户批量操作');
  229. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  230. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  231. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个用户');
  232. const userFields = await schemaFor('_User');
  233. const query = new Parse.Query('_User'); applyTenant(query, userFields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  234. const targets = await query.find({ useMasterKey: true });
  235. if (targets.length !== objectIds.length) fail(404, '部分用户不存在或不属于当前帐套');
  236. for (const target of targets) assertCanManageUser(context, target, action);
  237. let group = null;
  238. let groupId = 0;
  239. if (action === 'move') {
  240. groupId = Number(input.groupId);
  241. if (!Number.isInteger(groupId) || groupId < 1) fail(400, '请选择有效用户组');
  242. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  243. if (companyIds.length !== 1) fail(400, '批量移动的用户必须属于同一帐套');
  244. const groupRow = await Psql.oneOrNone('SELECT "objectId" FROM "Group" WHERE "company"=$1 AND "groupId"=$2 LIMIT 1', [companyIds[0], groupId]);
  245. if (!groupRow) fail(404, '目标用户组不存在或不属于当前帐套');
  246. group = await new Parse.Query('Group').get(String(groupRow.objectId), { useMasterKey: true });
  247. }
  248. const now = new Date();
  249. for (const target of targets) {
  250. const legacyData = { ...(target.get('legacyUserData') || {}) };
  251. if (action === 'move') {
  252. target.set('legacyGroupId', groupId); legacyData.GroupID = groupId;
  253. } else {
  254. const disabled = action === 'lock'; target.set('isDisabled', disabled); legacyData.State = disabled ? 0 : 1;
  255. if (userFields.statusAction) target.set('statusAction', disabled ? 'admin-lock' : 'admin-unlock');
  256. if (userFields.statusReason) target.set('statusReason', String(input.reason || '').trim().slice(0, 200));
  257. if (userFields.statusUpdatedAt) target.set('statusUpdatedAt', now);
  258. if (userFields.statusUpdatedBy) target.set('statusUpdatedBy', context.current.id);
  259. }
  260. target.set('legacyUserData', legacyData);
  261. }
  262. await Parse.Object.saveAll(targets, { useMasterKey: true });
  263. const revokedSessions = action === 'lock' ? await revokeSessions(targets) : 0;
  264. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'user-' + action, '_User', target.id);
  265. return response.json({ success: true, data: { action, updated: targets.length, revokedSessions, group: group ? serializeObject(group) : null, results: targets.map(serializeObject) } });
  266. }
  267. if (operation === 'saveGroup') {
  268. const objectId = String(input.objectId || '');
  269. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  270. const groupName = String(payload.groupName || '').trim();
  271. const description = String(payload.description || groupName).trim();
  272. const parentGroupId = payload.parentGroupId === undefined || payload.parentGroupId === '' ? 0 : Number(payload.parentGroupId);
  273. if (!groupName || groupName.length > 100) fail(400, '用户组名称为必填项且不能超过 100 位');
  274. if (description.length > 500) fail(400, '用户组说明不能超过 500 位');
  275. if (!Number.isInteger(parentGroupId) || parentGroupId < 0) fail(400, '父用户组编号无效');
  276. let companyId = pointerId(context.company);
  277. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  278. if (!companyId) fail(400, '用户组必须指定帐套');
  279. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  280. const groupFields = await schemaFor('Group');
  281. let target;
  282. let currentGroupId = 0;
  283. if (objectId) {
  284. const query = new Parse.Query('Group'); applyTenant(query, groupFields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  285. currentGroupId = Number(target.get('groupId')) || 0;
  286. } else target = new Parse.Object('Group');
  287. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Group" WHERE "company"=$1 AND LOWER(COALESCE("groupName",\'\'))=LOWER($2) AND ($3=\'\' OR "objectId"<>$3) LIMIT 1', [companyId, groupName, objectId]);
  288. if (duplicate) fail(409, '当前帐套已存在同名用户组');
  289. if (parentGroupId) {
  290. let cursor = parentGroupId; const visited = new Set();
  291. while (cursor) {
  292. if (cursor === currentGroupId || visited.has(cursor)) fail(400, '父用户组不能形成循环');
  293. visited.add(cursor);
  294. const parent = await Psql.oneOrNone('SELECT "parentGroupId" AS parent_id FROM "Group" WHERE "company"=$1 AND "groupId"=$2 LIMIT 1', [companyId, cursor]);
  295. if (!parent) fail(404, '父用户组不存在或不属于当前帐套');
  296. cursor = Number(parent.parent_id) || 0;
  297. if (visited.size > 100) fail(400, '用户组层级过深');
  298. }
  299. }
  300. target.set('company', company); target.set('groupName', groupName); target.set('description', description); target.set('parentGroupId', parentGroupId);
  301. const stringFields = ['otherName','enroll','signImg'];
  302. const numberFields = ['orderId','companyGroup','vipgroup','rebateRate','credit','vipnum','upPoint','upSicon','favCount','consumeType','ccountPerDay','upGradeMoney'];
  303. for (const name of stringFields) if (payload[name] !== undefined && groupFields[name]) target.set(name, String(payload[name] || '').trim());
  304. for (const name of numberFields) if (payload[name] !== undefined && payload[name] !== '') { const value = Number(payload[name]); if (!Number.isFinite(value)) fail(400, name + ' 必须是数字'); if (groupFields[name]) target.set(name, value); }
  305. if (payload.regSelect !== undefined && groupFields.regSelect) target.set('regSelect', payload.regSelect === true || payload.regSelect === 'true');
  306. if (!objectId && groupFields.sourceKey) target.set('sourceKey', 'cloud:admin-group:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10));
  307. await target.save(null, { useMasterKey: true });
  308. if (!objectId) {
  309. try {
  310. const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-group-id\'))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("groupId"),0)+1 AS id FROM "Group",lock_row WHERE "company"=$1 AND COALESCE("groupId",0)>0) UPDATE "Group" SET "groupId"=next_id.id,"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]);
  311. currentGroupId = Number(rows[0] && rows[0].id) || 0;
  312. if (!currentGroupId) throw new Error('无法分配用户组编号');
  313. } catch (error) { await target.destroy({ useMasterKey: true }).catch(() => undefined); throw error; }
  314. }
  315. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-group' : 'create-group', 'Group', target.id);
  316. return response.json({ success: true, data: serializeObject(target) });
  317. }
  318. if (operation === 'saveNode') {
  319. const objectId = String(input.objectId || '');
  320. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  321. const nodeName = String(payload.nodeName || '').trim();
  322. const nodeDir = String(payload.nodeDir || '').trim();
  323. const parentId = Number(input.parentId == null ? payload.parentId || 0 : input.parentId);
  324. if (!nodeName || nodeName.length > 100) fail(400, '栏目名称长度应为 1 至 100 位');
  325. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父节点编号无效');
  326. let companyId = pointerId(context.company);
  327. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  328. if (!companyId) fail(400, '栏目必须指定帐套');
  329. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  330. const fields = await schemaFor('Node');
  331. let target;
  332. let currentNodeId = 0;
  333. if (objectId) {
  334. const query = new Parse.Query('Node'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  335. companyId = pointerId(target.get('company')) || companyId;
  336. currentNodeId = Number(target.get('nodeId')) || 0;
  337. } else target = new Parse.Object('Node');
  338. let parentDepth = 0;
  339. if (parentId > 0) {
  340. const parent = await Psql.oneOrNone('SELECT "nodeId","depth" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 AND COALESCE("zstatus",99)<>-2 LIMIT 1', [companyId, parentId]);
  341. if (!parent) fail(404, '父节点不存在、已回收或不属于当前帐套');
  342. parentDepth = Number(parent.depth) || 0;
  343. if (currentNodeId) {
  344. const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "nodeId","parentId",ARRAY["nodeId"::text] AS path FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 UNION ALL SELECT n."nodeId",n."parentId",chain.path||n."nodeId"::text FROM "Node" n JOIN chain ON n."nodeId"=chain."parentId" WHERE n."company"=$1 AND NOT n."nodeId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "nodeId"=$3 LIMIT 1', [companyId, parentId, currentNodeId]);
  345. if (cycle) fail(409, '不能把栏目移动到自身或其下级栏目');
  346. }
  347. }
  348. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Node" WHERE "company"=$1 AND COALESCE("parentId",0)=$2 AND "objectId"<>$3 AND COALESCE("zstatus",99)<>-2 AND (LOWER(TRIM(COALESCE("nodeName",\'\')))=LOWER($4) OR ($5<>\'\' AND LOWER(TRIM(COALESCE("nodeDir",\'\')))=LOWER($5))) LIMIT 1', [companyId, parentId, objectId, nodeName, nodeDir]);
  349. if (duplicate) fail(409, '同一父栏目下的栏目名称或目录名不能重复');
  350. for (const [name, value] of Object.entries(payload)) {
  351. if (!fields[name] || isSystemField('Node', name)) continue;
  352. if (!GENERIC_WRITE_TYPES.has(fields[name].type)) fail(400, '字段不允许编辑: ' + name);
  353. if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
  354. }
  355. target.set('nodeName', nodeName); target.set('nodeDir', nodeDir); target.set('parentId', parentId); target.set('depth', parentDepth + 1); target.set('company', company);
  356. if (!objectId) {
  357. target.set('sourceKey', 'cloud:admin-node:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10));
  358. target.set('zstatus', 99); target.set('child', 0); target.set('cdate', new Date());
  359. if (fields.cuser) target.set('cuser', Number(context.current.get('legacyUserId')) || 0);
  360. if (fields.cuname) target.set('cuname', String(context.current.get('username') || ''));
  361. }
  362. if (fields.editDate) target.set('editDate', new Date());
  363. try { await target.save(null, { useMasterKey: true }); }
  364. catch (error) { fail(422, '栏目初始写入失败: ' + String(error && error.message || error)); }
  365. try {
  366. if (!objectId) {
  367. const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-node-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("nodeId"),0)+1 AS id FROM "Node",lock_row WHERE "company"=$1 AND COALESCE("nodeId",0)>0), next_order AS MATERIALIZED (SELECT COALESCE(MAX("orderId"),0)+1 AS id FROM "Node",lock_row WHERE "company"=$1 AND COALESCE("parentId",0)=$3 AND "objectId"<>$2) UPDATE "Node" SET "nodeId"=next_id.id,"orderId"=next_order.id,"sourceKey"=\'[["NodeID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id, parentId]);
  368. currentNodeId = Number(rows[0] && rows[0].id) || 0;
  369. if (!currentNodeId) throw new Error('无法分配栏目编号');
  370. }
  371. await Psql.query('WITH RECURSIVE tree AS (SELECT "objectId","nodeId",$3::numeric AS depth FROM "Node" WHERE "company"=$1 AND "objectId"=$2 UNION ALL SELECT n."objectId",n."nodeId",tree.depth+1 FROM "Node" n JOIN tree ON n."parentId"=tree."nodeId" WHERE n."company"=$1) UPDATE "Node" n SET "depth"=tree.depth,"updatedAt"=NOW() FROM tree WHERE n."objectId"=tree."objectId"', [companyId, target.id, parentDepth + 1]);
  372. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '栏目结构写入失败: ' + String(error && error.message || error)); }
  373. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-node' : 'create-node', 'Node', target.id);
  374. return response.json({ success: true, data: serializeObject(target) });
  375. }
  376. if (operation === 'nodeBatch') {
  377. const action = String(input.action || '');
  378. if (!['recycle','recover','move','purge'].includes(action)) fail(400, '不支持的栏目批量操作');
  379. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  380. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  381. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个栏目');
  382. const fields = await schemaFor('Node');
  383. const query = new Parse.Query('Node'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  384. const targets = await query.find({ useMasterKey: true });
  385. if (targets.length !== objectIds.length) fail(404, '部分栏目不存在或不属于当前帐套');
  386. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  387. if (companyIds.length !== 1) fail(400, '批量操作的栏目必须属于同一帐套');
  388. const companyId = companyIds[0];
  389. const nodeIds = targets.map((target) => Number(target.get('nodeId')) || 0);
  390. if (nodeIds.some((nodeId) => nodeId < 1)) fail(409, '栏目缺少有效旧系统编号');
  391. let parentId = null;
  392. let parentDepth = 0;
  393. if (action === 'move') {
  394. parentId = Number(input.parentId);
  395. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '目标父节点编号无效');
  396. if (parentId > 0) {
  397. const parent = await Psql.oneOrNone('SELECT "nodeId","depth" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 AND COALESCE("zstatus",99)<>-2 LIMIT 1', [companyId, parentId]);
  398. if (!parent) fail(404, '目标父节点不存在、已回收或不属于当前帐套');
  399. parentDepth = Number(parent.depth) || 0;
  400. for (const nodeId of nodeIds) {
  401. const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "nodeId","parentId",ARRAY["nodeId"::text] AS path FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 UNION ALL SELECT n."nodeId",n."parentId",chain.path||n."nodeId"::text FROM "Node" n JOIN chain ON n."nodeId"=chain."parentId" WHERE n."company"=$1 AND NOT n."nodeId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "nodeId"=$3 LIMIT 1', [companyId, parentId, nodeId]);
  402. if (cycle) fail(409, '不能把栏目移动到自身或其下级栏目');
  403. }
  404. }
  405. for (const target of targets) { target.set('parentId', parentId); target.set('depth', parentDepth + 1); if (fields.editDate) target.set('editDate', new Date()); }
  406. await Parse.Object.saveAll(targets, { useMasterKey: true });
  407. for (const target of targets) await Psql.query('WITH RECURSIVE tree AS (SELECT "objectId","nodeId",$3::numeric AS depth FROM "Node" WHERE "company"=$1 AND "objectId"=$2 UNION ALL SELECT n."objectId",n."nodeId",tree.depth+1 FROM "Node" n JOIN tree ON n."parentId"=tree."nodeId" WHERE n."company"=$1) UPDATE "Node" n SET "depth"=tree.depth,"updatedAt"=NOW() FROM tree WHERE n."objectId"=tree."objectId"', [companyId, target.id, parentDepth + 1]);
  408. } else if (action === 'purge') {
  409. const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "CommonModel" WHERE "company"=$1 AND "nodeId"=ANY($2::numeric[])) AS contents,(SELECT COUNT(*)::int FROM "Node" WHERE "company"=$1 AND "parentId"=ANY($2::numeric[]) AND NOT ("nodeId"=ANY($2::numeric[]))) AS children,(SELECT COUNT(*)::int FROM "NodeAuth" WHERE "nodeId"=ANY($2::numeric[])) AS auth,(SELECT COUNT(*)::int FROM "NodeModelTemplate" WHERE "nodeId"=ANY($2::numeric[])) AS templates', [companyId, nodeIds]);
  410. if (Number(refs.contents) || Number(refs.children) || Number(refs.auth) || Number(refs.templates)) fail(409, '栏目仍被内容、下级栏目、权限或模板引用,不能永久删除');
  411. await Parse.Object.destroyAll(targets, { useMasterKey: true });
  412. } else {
  413. const status = action === 'recycle' ? -2 : 99;
  414. for (const target of targets) { target.set('zstatus', status); if (fields.editDate) target.set('editDate', new Date()); }
  415. await Parse.Object.saveAll(targets, { useMasterKey: true });
  416. }
  417. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'node-' + action, 'Node', target.id);
  418. return response.json({ success: true, data: { action, updated: targets.length, parentId, results: action === 'purge' ? [] : targets.map(serializeObject) } });
  419. }
  420. if (operation === 'saveSpecial') {
  421. const objectId = String(input.objectId || '');
  422. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  423. const specName = String(payload.specName || '').trim();
  424. const specDir = String(payload.specDir || '').trim();
  425. const pid = Number(input.pid == null ? payload.pid || 0 : input.pid);
  426. if (!specName || specName.length > 100) fail(400, '专题名称长度应为 1 至 100 位');
  427. if (!specDir || specDir.length > 100) fail(400, '专题目录长度应为 1 至 100 位');
  428. if (!Number.isInteger(pid) || pid < 0) fail(400, '父专题编号无效');
  429. let companyId = pointerId(context.company);
  430. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  431. if (!companyId) fail(400, '专题必须指定帐套');
  432. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  433. const fields = await schemaFor('Special');
  434. let target;
  435. let currentSpecId = 0;
  436. if (objectId) {
  437. const query = new Parse.Query('Special'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  438. companyId = pointerId(target.get('company')) || companyId; currentSpecId = Number(target.get('specId')) || 0;
  439. } else target = new Parse.Object('Special');
  440. if (pid > 0) {
  441. const parent = await Psql.oneOrNone('SELECT "specId" FROM "Special" WHERE "company"=$1 AND "specId"=$2 LIMIT 1', [companyId, pid]);
  442. if (!parent) fail(404, '父专题不存在或不属于当前帐套');
  443. if (currentSpecId) {
  444. const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "specId","pid",ARRAY["specId"::text] AS path FROM "Special" WHERE "company"=$1 AND "specId"=$2 UNION ALL SELECT s."specId",s."pid",chain.path||s."specId"::text FROM "Special" s JOIN chain ON s."specId"=chain."pid" WHERE s."company"=$1 AND NOT s."specId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "specId"=$3 LIMIT 1', [companyId, pid, currentSpecId]);
  445. if (cycle) fail(409, '不能把专题移动到自身或其下级专题');
  446. }
  447. }
  448. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Special" WHERE "company"=$1 AND "objectId"<>$2 AND (LOWER(TRIM(COALESCE("specName",\'\')))=LOWER($3) OR LOWER(TRIM(COALESCE("specDir",\'\')))=LOWER($4)) LIMIT 1', [companyId, objectId, specName, specDir]);
  449. if (duplicate) fail(409, '专题名称或目录不能重复');
  450. for (const [name, value] of Object.entries(payload)) {
  451. if (!fields[name] || isSystemField('Special', name)) continue;
  452. if (!GENERIC_WRITE_TYPES.has(fields[name].type)) fail(400, '字段不允许编辑: ' + name);
  453. if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
  454. }
  455. target.set('specName', specName); target.set('specDir', specDir); target.set('pid', pid); target.set('company', company); target.set('editDate', new Date());
  456. if (!objectId) { target.set('sourceKey', 'cloud:admin-special:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('openType', true); target.set('cdate', new Date()); target.set('cuser', String(context.current.get('username') || '')); }
  457. try { await target.save(null, { useMasterKey: true }); }
  458. catch (error) { fail(422, '专题初始写入失败: ' + String(error && error.message || error)); }
  459. try {
  460. if (!objectId) {
  461. const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-special-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("specId"),0)+1 AS id FROM "Special",lock_row WHERE "company"=$1 AND COALESCE("specId",0)>0), next_order AS MATERIALIZED (SELECT COALESCE(MAX("orderId"),0)+1 AS id FROM "Special",lock_row WHERE "company"=$1 AND COALESCE("pid",0)=$3 AND "objectId"<>$2) UPDATE "Special" SET "specId"=next_id.id,"orderId"=next_order.id,"sourceKey"=\'[["SpecID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id, pid]);
  462. currentSpecId = Number(rows[0] && rows[0].id) || 0;
  463. if (!currentSpecId) throw new Error('无法分配专题编号');
  464. }
  465. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '专题结构写入失败: ' + String(error && error.message || error)); }
  466. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-special' : 'create-special', 'Special', target.id);
  467. return response.json({ success: true, data: serializeObject(target) });
  468. }
  469. if (operation === 'specialBatch') {
  470. const action = String(input.action || '');
  471. if (action === 'merge') fail(501, 'migration_blocked: CommonModel.specialId 仅存在于 Parse Schema、PostgreSQL 物理列缺失,且商品主表未迁移,无法安全合并专题关系');
  472. if (action !== 'move') fail(400, '不支持的专题批量操作');
  473. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  474. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  475. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个专题');
  476. const fields = await schemaFor('Special');
  477. const query = new Parse.Query('Special'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  478. const targets = await query.find({ useMasterKey: true });
  479. if (targets.length !== objectIds.length) fail(404, '部分专题不存在或不属于当前帐套');
  480. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  481. if (companyIds.length !== 1) fail(400, '批量操作的专题必须属于同一帐套');
  482. const companyId = companyIds[0];
  483. const specIds = targets.map((target) => Number(target.get('specId')) || 0);
  484. const pid = Number(input.pid);
  485. if (!Number.isInteger(pid) || pid < 0) fail(400, '目标父专题编号无效');
  486. if (pid > 0) {
  487. const parent = await Psql.oneOrNone('SELECT "specId" FROM "Special" WHERE "company"=$1 AND "specId"=$2 LIMIT 1', [companyId, pid]);
  488. if (!parent) fail(404, '目标父专题不存在或不属于当前帐套');
  489. for (const specId of specIds) {
  490. const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "specId","pid",ARRAY["specId"::text] AS path FROM "Special" WHERE "company"=$1 AND "specId"=$2 UNION ALL SELECT s."specId",s."pid",chain.path||s."specId"::text FROM "Special" s JOIN chain ON s."specId"=chain."pid" WHERE s."company"=$1 AND NOT s."specId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "specId"=$3 LIMIT 1', [companyId, pid, specId]);
  491. if (cycle) fail(409, '不能把专题移动到自身或其下级专题');
  492. }
  493. }
  494. for (const target of targets) { target.set('pid', pid); target.set('editDate', new Date()); }
  495. await Parse.Object.saveAll(targets, { useMasterKey: true });
  496. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'special-move', 'Special', target.id);
  497. return response.json({ success: true, data: { action, updated: targets.length, pid, results: targets.map(serializeObject) } });
  498. }
  499. if (operation === 'saveModelMetadata' || operation === 'saveModelFieldMetadata') {
  500. const className = operation === 'saveModelMetadata' ? 'Model' : 'ModelField';
  501. const objectId = String(input.objectId || '');
  502. if (!objectId) fail(501, 'migration_blocked: 新增模型或字段需要同步创建 PostgreSQL 物理表/列及旧模板文件,托管云函数中未开放此 DDL 流程');
  503. const fields = await schemaFor(className);
  504. const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
  505. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  506. const allowed = className === 'Model'
  507. ? new Set(['modelName','itemName','itemUnit','itemIcon','description','islotsize','contentTemplate','thumbnail'])
  508. : new Set(['fieldAlias','fieldTips','description','isNotNull','isSearchForm','content','isShow','isView','showList','showWidth','isCopy','islotsize','isChain']);
  509. if (className === 'Model') {
  510. const modelName = String(payload.modelName == null ? target.get('modelName') || '' : payload.modelName).trim();
  511. if (!modelName || modelName.length > 100) fail(400, '模型名称长度应为 1 至 100 位');
  512. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Model" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("modelName",\'\')))=LOWER($3) LIMIT 1', [pointerId(target.get('company')), objectId, modelName]);
  513. if (duplicate) fail(409, '模型名称已存在');
  514. target.set('modelName', modelName);
  515. } else {
  516. const fieldAlias = String(payload.fieldAlias == null ? target.get('fieldAlias') || '' : payload.fieldAlias).trim();
  517. if (!fieldAlias || fieldAlias.length > 100) fail(400, '字段别名长度应为 1 至 100 位');
  518. target.set('fieldAlias', fieldAlias);
  519. }
  520. for (const [name, value] of Object.entries(payload)) {
  521. if (!allowed.has(name) || !fields[name]) continue;
  522. if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
  523. }
  524. await target.save(null, { useMasterKey: true }); await audit(context, className === 'Model' ? 'update-model-metadata' : 'update-model-field-metadata', className, objectId);
  525. return response.json({ success: true, data: serializeObject(target) });
  526. }
  527. if (operation === 'modelFieldOrder') {
  528. const items = Array.isArray(input.items) ? input.items : [];
  529. if (!items.length || items.length > 100) fail(400, '每次请提交 1 至 100 个字段顺序');
  530. const objectIds = items.map((item) => String(item && item.objectId || '').trim());
  531. const orderIds = items.map((item) => Number(item && item.orderId));
  532. if (objectIds.some((id) => !id) || new Set(objectIds).size !== objectIds.length || orderIds.some((id) => !Number.isInteger(id) || id < 0) || new Set(orderIds).size !== orderIds.length) fail(400, '字段或顺序参数无效/重复');
  533. const fields = await schemaFor('ModelField');
  534. const query = new Parse.Query('ModelField'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  535. const targets = await query.find({ useMasterKey: true });
  536. if (targets.length !== objectIds.length) fail(404, '部分模型字段不存在或不属于当前帐套');
  537. const modelIds = [...new Set(targets.map((target) => Number(target.get('modelId')) || 0))];
  538. if (modelIds.length !== 1) fail(400, '只能对同一模型的字段排序');
  539. const targetMap = new Map(targets.map((target) => [target.id, target]));
  540. for (const item of items) targetMap.get(String(item.objectId)).set('orderId', Number(item.orderId));
  541. await Parse.Object.saveAll(targets, { useMasterKey: true });
  542. for (const target of targets) await audit(context, 'order-model-field', 'ModelField', target.id);
  543. return response.json({ success: true, data: { updated: targets.length, modelId: modelIds[0], results: targets.map(serializeObject) } });
  544. }
  545. if (operation === 'saveGuestbook') {
  546. const objectId = String(input.objectId || '');
  547. if (!objectId) fail(400, '普通留言新增必须走前台留言云函数;后台只能通过专用回复流程新增');
  548. const fields = await schemaFor('Guestbook');
  549. const query = new Parse.Query('Guestbook'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
  550. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  551. const title = String(payload.title == null ? target.get('title') || '' : payload.title).trim();
  552. const content = String(payload.tcontent == null ? target.get('tcontent') || '' : payload.tcontent);
  553. if (!title || title.length > 200) fail(400, '留言标题长度应为 1 至 200 位');
  554. if (content.length > 200000) fail(400, '留言内容过长');
  555. target.set('title', title); target.set('tcontent', content);
  556. await target.save(null, { useMasterKey: true }); await audit(context, 'update-guestbook', 'Guestbook', objectId);
  557. return response.json({ success: true, data: serializeObject(target) });
  558. }
  559. if (operation === 'guestbookReply') {
  560. const parentObjectId = String(input.parentObjectId || '');
  561. const title = String(input.title || '').trim() || '[管理员回复]';
  562. const content = String(input.content || '');
  563. if (!parentObjectId) fail(400, '缺少原留言');
  564. if (title.length > 200 || !content.trim() || content.length > 200000) fail(400, '回复标题或内容无效');
  565. const fields = await schemaFor('Guestbook');
  566. const query = new Parse.Query('Guestbook'); applyTenant(query, fields, context, input.companyId); const parent = await query.get(parentObjectId, { useMasterKey: true });
  567. const company = parent.get('company') || context.company; const companyId = pointerId(company); const parentGid = Number(parent.get('gid')) || 0; const cateid = Number(parent.get('cateid')) || 0;
  568. if (!companyId || !parentGid) fail(409, '原留言缺少帐套或旧系统编号');
  569. const reply = new Parse.Object('Guestbook');
  570. reply.set('sourceKey', 'cloud:admin-guestbook-reply:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); reply.set('company', company); reply.set('parentid', parentGid); reply.set('cateid', cateid); reply.set('userid', Number(context.current.get('legacyUserId')) || 0); reply.set('title', title); reply.set('tcontent', content); reply.set('status', 99); reply.set('gdate', new Date()); reply.set('ip', 'admin-cloud');
  571. try { await reply.save(null, { useMasterKey: true }); }
  572. catch (error) { fail(422, '管理员回复初始写入失败: ' + String(error && error.message || error)); }
  573. try {
  574. const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-guestbook-gid:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("gid"),0)+1 AS id FROM "Guestbook",lock_row WHERE "company"=$1 AND COALESCE("gid",0)>0) UPDATE "Guestbook" SET "gid"=next_id.id,"sourceKey"=\'[["Gid",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, reply.id]);
  575. if (!Number(rows[0] && rows[0].id)) throw new Error('无法分配回复编号');
  576. } catch (error) { await reply.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '管理员回复结构写入失败: ' + String(error && error.message || error)); }
  577. await reply.fetch({ useMasterKey: true }); await audit({ ...context, company }, 'reply-guestbook', 'Guestbook', reply.id);
  578. return response.json({ success: true, data: serializeObject(reply) });
  579. }
  580. if (operation === 'guestbookBatch') {
  581. const action = String(input.action || '');
  582. if (!['audit','unaudit','recycle','recover','purge'].includes(action)) fail(400, '不支持的留言批量操作');
  583. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  584. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  585. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条留言');
  586. const fields = await schemaFor('Guestbook');
  587. const query = new Parse.Query('Guestbook'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  588. const targets = await query.find({ useMasterKey: true });
  589. if (targets.length !== objectIds.length) fail(404, '部分留言不存在或不属于当前帐套');
  590. if (action === 'purge') {
  591. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))]; const gids = targets.map((target) => Number(target.get('gid')) || 0);
  592. if (companyIds.length !== 1 || gids.some((gid) => gid < 1)) fail(409, '留言缺少有效帐套或编号');
  593. const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "Guestbook" WHERE "company"=$1 AND "parentid"=ANY($2::numeric[]) AND NOT ("gid"=ANY($2::numeric[]))', [companyIds[0], gids]);
  594. if (Number(children.count) > 0) fail(409, '留言仍有回复,不能永久删除');
  595. await Parse.Object.destroyAll(targets, { useMasterKey: true });
  596. } else {
  597. const status = action === 'recycle' ? -2 : action === 'unaudit' ? 0 : 99;
  598. for (const target of targets) target.set('status', status);
  599. await Parse.Object.saveAll(targets, { useMasterKey: true });
  600. }
  601. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'guestbook-' + action, 'Guestbook', target.id);
  602. return response.json({ success: true, data: { action, updated: targets.length, results: action === 'purge' ? [] : targets.map(serializeObject) } });
  603. }
  604. if (operation === 'contentBatch') {
  605. const action = String(input.action || '');
  606. if (!['status','recycle','recover','move'].includes(action)) fail(400, '不支持的内容批量操作');
  607. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  608. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  609. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条内容');
  610. const contentFields = await schemaFor('CommonModel');
  611. const query = new Parse.Query('CommonModel'); applyTenant(query, contentFields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  612. const targets = await query.find({ useMasterKey: true });
  613. if (targets.length !== objectIds.length) fail(404, '部分内容不存在或不属于当前帐套');
  614. let status = null;
  615. let nodeId = null;
  616. if (action === 'status') {
  617. status = Number(input.status);
  618. if (![-3,-1,0,99].includes(status)) fail(400, '不支持的内容状态');
  619. } else if (action === 'recycle') status = -2;
  620. else if (action === 'recover') status = 0;
  621. else {
  622. nodeId = Number(input.nodeId);
  623. if (!Number.isInteger(nodeId) || nodeId < 1) fail(400, '请选择有效目标节点');
  624. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  625. if (companyIds.length !== 1) fail(400, '批量移动的内容必须属于同一帐套');
  626. const node = await Psql.oneOrNone('SELECT "objectId" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 AND COALESCE("zstatus",99)<>-2 LIMIT 1', [companyIds[0], nodeId]);
  627. if (!node) fail(404, '目标节点不存在或不属于当前帐套');
  628. }
  629. for (const target of targets) {
  630. if (status !== null) target.set('status', status);
  631. if (nodeId !== null) target.set('nodeId', nodeId);
  632. }
  633. await Parse.Object.saveAll(targets, { useMasterKey: true });
  634. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'content-' + action, 'CommonModel', target.id);
  635. return response.json({ success: true, data: { action, updated: targets.length, status, nodeId, results: targets.map(serializeObject) } });
  636. }
  637. const className = String(input.className || '');
  638. assertClass(className);
  639. const fields = await schemaFor(className);
  640. const classWritable = !READ_ONLY_CLASSES.has(className);
  641. if (operation === 'schema') {
  642. 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 && !isSystemField(className, name) && GENERIC_WRITE_TYPES.has(field.type) }));
  643. return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && !['CommonModel','Model','ModelField','Guestbook','Guestcate'].includes(className), supportsSoftDelete: Boolean(fields.isDeleted) } });
  644. }
  645. if (operation === 'list') {
  646. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
  647. let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId);
  648. const search = String(input.search || '').trim();
  649. if (search) { const searchField = ['name','title','nodeName','specName','modelName','fieldName','fieldAlias','groupName','username','realName','mobile','sourceKey'].find((name) => fields[name] && fields[name].type === 'String'); if (searchField) query.matches(searchField, escapeRegex(search), 'i'); }
  650. const sort = fields[input.sort] ? String(input.sort) : fields.updatedAt ? 'updatedAt' : 'createdAt'; if (input.order === 'asc') query.ascending(sort); else query.descending(sort);
  651. const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize);
  652. const results = (await query.find({ useMasterKey: true })).filter(isVisible);
  653. return response.json({ success: true, data: { className, page, pageSize, total, results: results.map(serializeObject) } });
  654. }
  655. const objectId = String(input.objectId || '');
  656. if (operation === 'get') {
  657. 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 });
  658. return response.json({ success: true, data: serializeObject(object) });
  659. }
  660. if (operation === 'save') {
  661. if (!classWritable) fail(403, '该系统类不允许通用编辑');
  662. if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程');
  663. if (className === 'Group') fail(400, '用户组必须走专用保存流程');
  664. if (className === 'Node') fail(400, '栏目必须走专用保存流程');
  665. if (className === 'Special') fail(400, '专题必须走专用保存流程');
  666. if (className === 'Model' || className === 'ModelField') fail(400, '模型结构必须走专用元数据流程');
  667. if (className === 'Guestbook') fail(400, '留言必须走专用编辑或回复流程');
  668. if (className === 'Guestcate') fail(501, 'migration_blocked: 留言/贴吧分类需要专用原子分类编号、类型与父子引用流程,通用保存已禁用');
  669. if (className === 'CommonModel' && !objectId) fail(400, '内容新增必须同时写入模型附表,不能走通用保存');
  670. let object;
  671. 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);
  672. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  673. for (const [name, value] of Object.entries(payload)) { if (!fields[name] || isSystemField(className, name)) continue; if (!GENERIC_WRITE_TYPES.has(fields[name].type)) fail(400, '字段不允许通用编辑: ' + name); if (value === null) object.unset(name); else object.set(name, toParseValue(fields[name], value)); }
  674. if (fields.company && context.company) object.set('company', context.company); if (fields.isDeleted && !objectId) object.set('isDeleted', false);
  675. await object.save(null, { useMasterKey: true }); await audit(context, objectId ? 'update' : 'create', className, object.id);
  676. return response.json({ success: true, data: serializeObject(object) });
  677. }
  678. if (operation === 'delete') {
  679. 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 });
  680. if (className === 'Model' || className === 'ModelField') fail(501, 'migration_blocked: 删除模型或字段需要同步删除 PostgreSQL 物理表/列并验证历史数据,托管云函数中未开放此 DDL 流程');
  681. if (className === 'Guestcate') fail(501, 'migration_blocked: 分类删除前需要同时核验留言、帖子、权限与下级分类引用,通用删除已禁用');
  682. if (className === '_User') assertCanManageUser(context, object, 'lock');
  683. if (className === 'Group') { const groupId = Number(object.get('groupId')) || 0; const companyId = pointerId(object.get('company')); const users = await Psql.one('SELECT COUNT(*)::int AS count FROM "_User" WHERE "company"=$1 AND COALESCE("legacyGroupId",0)=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)', [companyId, groupId]); const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "Group" WHERE "company"=$1 AND COALESCE("parentGroupId",0)=$2', [companyId, groupId]); if (Number(users.count) > 0) fail(409, '该用户组仍有用户,不能删除'); if (Number(children.count) > 0) fail(409, '该用户组仍有下级组,不能删除'); }
  684. if (className === 'CommonModel') { object.set('status', -2); await object.save(null, { useMasterKey: true }); await audit(context, 'content-recycle', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: true, status: -2 } }); }
  685. if (className === 'Node') { object.set('zstatus', -2); if (fields.editDate) object.set('editDate', new Date()); await object.save(null, { useMasterKey: true }); await audit(context, 'node-recycle', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: true, zstatus: -2 } }); }
  686. if (className === 'Special') { const specId = Number(object.get('specId')) || 0; const companyId = pointerId(object.get('company')); const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "Special" WHERE "company"=$1 AND COALESCE("pid",0)=$2', [companyId, specId]); if (Number(children.count) > 0) fail(409, '该专题仍有下级专题,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-special', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
  687. if (className === 'Guestbook') { object.set('status', -2); await object.save(null, { useMasterKey: true }); await audit(context, 'guestbook-recycle', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: true, status: -2 } }); }
  688. if (fields.isDeleted) { object.set('isDeleted', true); await object.save(null, { useMasterKey: true }); } else await object.destroy({ useMasterKey: true });
  689. if (className === '_User') await revokeSessions([object]);
  690. await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } });
  691. }
  692. if (operation === 'resetPassword') {
  693. if (className !== '_User' || !objectId || typeof input.newPassword !== 'string' || input.newPassword.length < 8) fail(400, '密码至少 8 位');
  694. const query = new Parse.Query('_User'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); assertCanManageUser(context, target, 'reset-password'); target.setPassword(input.newPassword); if (fields.passwordResetRequired) target.set('passwordResetRequired', false); await target.save(null, { useMasterKey: true }); const revokedSessions = await revokeSessions([target]); await audit(context, 'reset-password', '_User', objectId); return response.json({ success: true, data: { objectId, revokedSessions } });
  695. }
  696. fail(400, '不支持的后台操作');
  697. } catch (error) {
  698. const status = Number(error.status || (error.code === 101 ? 404 : 500));
  699. return response.status(status).json({ success: false, error: status >= 500 ? 'cloud_function_error' : 'request_rejected', message: error.message || '云函数执行失败' });
  700. }
  701. }
  702. `;
  703. function cmsReadCode(mode) {
  704. return String.raw`
  705. const MODE = ${JSON.stringify(mode)};
  706. const HIDDEN = /(?:password|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword)/i;
  707. function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; }
  708. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  709. 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; }
  710. 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 }; }
  711. async function schema(name) { try { return (await new Parse.Schema(name).get({ useMasterKey: true })).fields || {}; } catch (_) { return {}; } }
  712. function tenant(query, fields, context) { if (fields.company && context.company) query.equalTo('company', context.company); }
  713. 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(); }
  714. function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
  715. async function handler(request, response) {
  716. try {
  717. 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));
  718. if (MODE === 'content-normalizer') return response.json({ success: true, data: { content: stripHtml(input.content), mode: String(input.mode || 'strip') } });
  719. if (MODE.startsWith('user-')) {
  720. 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);
  721. if (MODE === 'user-extended') { delete data.legacyUserPlat; delete data.legacyWxUser; delete data.wechat; delete data.wxapp; return response.json({ success: true, data }); }
  722. if (MODE === 'user-platform') return response.json({ success: true, data: { objectId: target.id, platform: safe(target.get('legacyUserPlat') || {}), company: safe(target.get('company')) } });
  723. return response.json({ success: true, data: { objectId: target.id, wechat: safe(target.get('legacyWxUser') || target.get('wechat') || target.get('wxapp') || {}) } });
  724. }
  725. if (MODE === 'exam-classes' || MODE === 'guest-bar') {
  726. 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) } });
  727. }
  728. if (MODE === 'search') {
  729. 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: [] } });
  730. 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; }) } });
  731. }
  732. fail(400, '不支持的 CMS 查询');
  733. } 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 || '云函数执行失败' }); }
  734. }
  735. `;
  736. }
  737. const appGatewayCode = String.raw`
  738. const DEFAULT_COMPANY_ID = '7pIbDBJmKx';
  739. const PUBLIC_READ = new Set(['content_list','content_get','content_uphis','node_list','node_get','app_update']);
  740. const BLOCKED = {
  741. mcode_send: '缺少新短信服务商凭据与验证码存储', user_login_mobile: '缺少新短信验证码服务', user_register_mobile: '缺少新短信验证码服务', user_sync2: '依赖微信容器授权与新版微信凭据',
  742. cart_list: '目标 Schema 无购物车类', coupon_list: '目标 Schema 无优惠券实例类', coupon_usrgot_add: '目标 Schema 无用户优惠券类', coupon_usrgot_list: '目标 Schema 无用户优惠券类',
  743. order_comment_add: '目标 Schema 无订单评价类', order_comment_list: '目标 Schema 无订单评价类', order_delivery: '目标 Schema 无订单与物流类', order_get: '目标 Schema 无订单类', order_list: '目标 Schema 无订单类', order_signfor: '目标 Schema 无订单类',
  744. payment_cart: '目标 Schema 无订单与支付明细类', payment_cart_again: '目标 Schema 无订单与支付明细类', payment_success: '目标 Schema 无支付明细类',
  745. invoice_add: '目标 Schema 无发票类', invoice_del: '目标 Schema 无发票类', invoice_get: '目标 Schema 无发票类', invoice_list: '目标 Schema 无发票类', invoice_upd: '目标 Schema 无发票类',
  746. receaddr_add: '目标 Schema 无收货地址类', receaddr_del: '目标 Schema 无收货地址类', receaddr_get: '目标 Schema 无收货地址类', receaddr_list: '目标 Schema 无收货地址类', receaddr_upd: '目标 Schema 无收货地址类',
  747. user_bank_add: '目标 Schema 无提现账户类', user_bank_del: '目标 Schema 无提现账户类', user_bank_get: '目标 Schema 无提现账户类', user_bank_list: '目标 Schema 无提现账户类', user_cash_add: '缺少提现事务账本', user_cash_list: '缺少提现事务账本',
  748. user_coin_recharge: '缺少支付凭据与事务账本', user_exp_transfer: '缺少积分事务规则确认', user_money_recharge: '缺少支付凭据与事务账本', user_money_transfer: '缺少资金事务规则确认',
  749. user_group_usr_supply: '目标 Schema 无会员续费订单类', user_group_usr_upgrade: '目标 Schema 无会员升级订单类', user_shop_order: '目标 Schema 无订单类', user_shop_sales: '目标 Schema 无销售明细类',
  750. user_star_add: '目标 Schema 无收藏关系类', user_star_del: '目标 Schema 无收藏关系类', user_star_is: '目标 Schema 无收藏关系类', user_update_paypwd: '需要独立支付密码哈希服务',
  751. user_update_pwd: '旧流程强制校验短信验证码,但新短信服务与验证码存储尚未提供', user_update_pwdall: '同时依赖登录密码与支付密码写入,目标系统缺少独立支付密码哈希服务',
  752. vote_add: '目标 Schema 无可证明的投票记录类', vote_ask: '目标 Schema 无可证明的投票记录类', vote_question: '目标 Schema 无可证明的投票记录类',
  753. product_list: '目标 Product 仅是 ZL_P_Product 附表,缺少 ZL_Commodities 商品主表', product_get: '目标 Product 仅是 ZL_P_Product 附表,缺少商品名称、价格、正文、所有者与主键关系', product_stock_list: '目标 Schema 无 ZL_Shop_Stock 库存流水类',
  754. product_del: '缺少 ZL_Commodities 主表及其 UserID 所有权字段', product_stock_change: '缺少 ZL_Commodities 主表、当前库存与 ZL_Shop_Stock 流水类', product_sale_change: '缺少 ZL_Commodities 主表及 Sales/UserID 字段',
  755. unit_record_update: '旧源码在调用前无条件 return;目标库也未迁移 Model 57 单元聚合副表',
  756. e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
  757. pub_add: '旧前端依赖 Pub 7-13,但目标库只迁入 Pub 2-6,且缺少注销、供应商、商户邀请、退换货与结算副表', pub_list: '旧前端依赖 Pub 7-13,但目标库只迁入 Pub 2-6,无法恢复对应历史记录与审核范围',
  758. user_rnauth_add: '实名认证需新的合规审核与敏感数据存储', user_rnauth_get: '实名认证需新的合规审核与敏感数据存储', user_rnauth_upd: '实名认证需新的合规审核与敏感数据存储',
  759. product_add: '缺少 ZL_Commodities 商品主表,无法与 Product 附表进行事务新增', product_upd: '缺少 ZL_Commodities 商品主表,无法恢复主表与附表事务更新'
  760. };
  761. const SENSITIVE = /(?:password|pwd|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword|payPassword)/i;
  762. function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; }
  763. function envelope(result, addon, page) { const value = { retcode: 0, retmsg: '', result }; if (addon !== undefined) value.addon = addon; if (page) value.page = page; return value; }
  764. function reject(message) { return { retcode: -1, retmsg: message, result: null }; }
  765. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  766. 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; }
  767. function number(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }
  768. 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))) }; }
  769. function companyPointer() { return Parse.Object.createWithoutData('Company', DEFAULT_COMPANY_ID); }
  770. async function fieldsOf(className) { return (await new Parse.Schema(className).get({ useMasterKey: true })).fields || {}; }
  771. function tenant(query, fields) { if (fields.company) query.equalTo('company', companyPointer()); }
  772. 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); }
  773. function legacyAliases(value, className) {
  774. const row = safe(value); const maps = {
  775. 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' },
  776. Node: { NodeID:'nodeId', NodeName:'nodeName', NodeType:'nodeType', NodeDir:'nodeDir', NodeUrl:'nodeUrl', ParentID:'parentId', OrderID:'orderId', NodePic:'nodePicUrl', Description:'description', ConsumePoint:'consumePoint', ConsumeDeposit:'consumeDeposit', ConsumeType:'consumeType', ConsumeTime:'consumeTime', ConsumeCount:'consumeCount', AddPoint:'addPoint' },
  777. App: { ID:'id' }
  778. }; 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;
  779. }
  780. 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; }
  781. async function revokeUserSessions(target) { let count = 0; while (true) { const query = new Parse.Query('_Session'); query.equalTo('user', target); query.limit(1000); const sessions = await query.find({ useMasterKey:true }); if (!sessions.length) break; await Parse.Object.destroyAll(sessions,{useMasterKey:true}); count += sessions.length; if (sessions.length < 1000) break; } return count; }
  782. function objectValue(source, key) { return source && typeof source.get === 'function' ? source.get(key) : source && source[key]; }
  783. function legacyData(source) { const value = objectValue(source, 'legacyUserData'); return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
  784. function firstValue(...values) { return values.find((value) => value !== undefined && value !== null && value !== ''); }
  785. function legacyUser(current, withToken = false) {
  786. const data = legacyData(current); const username = String(firstValue(objectValue(current,'username'),data.UserName,'') || ''); const userId = number(firstValue(objectValue(current,'legacyUserId'),objectValue(current,'userid'),objectValue(current,'num'),data.UserID)); const groupId = number(firstValue(objectValue(current,'legacyGroupId'),data.GroupID)); const honeyName = String(firstValue(objectValue(current,'nickname'),objectValue(current,'nickName'),objectValue(current,'realName'),data.HoneyName,username) || ''); const avatar = String(firstValue(objectValue(current,'avatar'),data.salt,'') || ''); const mobile = String(firstValue(objectValue(current,'mobile'),objectValue(current,'phone'),'') || ''); const parentId = number(firstValue(data.ParentUserID,objectValue(current,'puid'))); const vip = number(firstValue(data.VIP,objectValue(current,'vip')));
  787. const value = { objectId:String(objectValue(current,'objectId') || current.id || ''), userId, userName:username, honeyName, userFace:avatar, mobile, groupId, groupName:String(objectValue(current,'roleName') || ''), email:String(firstValue(objectValue(current,'email'),data.Email,'') || ''), puid:parentId, vip, regTime:firstValue(data.RegTime,objectValue(current,'createdAt')), birthday:firstValue(objectValue(current,'birthday'),data.birthday,''), wechat:firstValue(objectValue(current,'wechat'),data.wechat,''), seturl:String(firstValue(data.seturl,objectValue(current,'seturl'),'') || '') };
  788. if (withToken) value.sessionToken = current.getSessionToken(); return value;
  789. }
  790. function legacyUserAddon(current) {
  791. const data = legacyData(current); const state = objectValue(current,'isDisabled') === true ? 0 : number(firstValue(data.State,1),1); const result = { vip:number(data.VIP), state, State:state, regTime:firstValue(data.RegTime,objectValue(current,'createdAt')), purse:number(data.Purse), silverCoin:number(data.SilverCoin), userExp:number(data.UserExp), userPoint:number(data.UserPoint), boffExp:number(data.boffExp) }; result.VIP = result.vip; result.Purse = result.purse; result.SilverCoin = result.silverCoin; result.UserExp = result.userExp; result.UserPoint = result.userPoint; return result;
  792. }
  793. async function registerUser(input) {
  794. const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username) fail(400,'账号不能为空'); if (password.length < 6 || password.length > 18) fail(400,'密码长度应为 6 至 18 位'); const inviteCode = String(input.code || input.inviCode || '').trim(); let parentId = 0; if (inviteCode) { const rows = await Psql.query('SELECT COALESCE("legacyUserId",("legacyUserData"->>\'UserID\')::numeric) AS id FROM "_User" WHERE "company"=$1 AND "username"=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1',[DEFAULT_COMPANY_ID,inviteCode]); parentId = number(rows[0] && rows[0].id); if (!parentId) fail(400,'邀请码不存在'); }
  795. const company = await new Parse.Query('Company').get(DEFAULT_COMPANY_ID,{useMasterKey:true}); const created = new Parse.User(); created.setUsername(username); created.setPassword(password); created.set('company',company); created.set('type','user'); created.set('isDisabled',false); created.set('legacyGroupId',1); created.set('nickname',username); if (/^1\d{10}$/.test(username)) created.set('mobile',username); try { await created.signUp(); } catch (error) { if (number(error && error.code) === 202) fail(409,'此用户名已被使用'); throw error; }
  796. const sessionToken = created.getSessionToken(); try { const regTime = new Date().toISOString(); const data = { UserID:0,UserName:username,HoneyName:username,GroupID:1,ParentUserID:parentId,VIP:0,RegTime:regTime,Purse:0,SilverCoin:0,UserExp:0,UserPoint:0,boffExp:0,State:1 }; const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-legacy-user-id\'))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("legacyUserId"),0)+1 AS id FROM "_User",lock_row WHERE "company"=$1 AND COALESCE("legacyUserId",0)>0) UPDATE "_User" SET "legacyUserId"=next_id.id,"legacyGroupId"=1,"legacyUserData"=jsonb_set($3::jsonb,\'{UserID}\',to_jsonb(next_id.id),true),"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id',[DEFAULT_COMPANY_ID,created.id,JSON.stringify(data)]); const userId = number(rows[0] && rows[0].id); if (!userId) throw new Error('无法分配旧系统用户 ID'); await created.fetch({useMasterKey:true}); const result = legacyUser(created); result.sessionToken = sessionToken; return { result,addon:{ State:1,state:1,parentUserId:parentId } }; } catch (error) { await created.destroy({useMasterKey:true}).catch(() => undefined); throw error; }
  797. }
  798. async function updateUserProfile(input,current) {
  799. const ownId = ownLegacyId(current); const requestedId = number(input.uid || ownId); if (requestedId !== ownId && !isAdminUser(current)) fail(403,'不允许修改其他用户资料'); const model = parseObject(input.mu,'用户资料'); const fields = await fieldsOf('_User'); const source = { ...model,...input }; delete source.mu; const mappings = { honeyName:'nickname',nickname:'nickname',trueName:'realName',realName:'realName',userFace:'avatar',avatar:'avatar',mobile:'mobile',Email:'email',email:'email',sex:'gender',gender:'gender',birthday:'birthday',seturl:'seturl' }; const legacyMappings = { honeyName:'HoneyName',nickname:'HoneyName',trueName:'TrueName',realName:'TrueName',userFace:'salt',avatar:'salt',mobile:'Mobile',Email:'Email',email:'Email',sex:'Sex',gender:'Sex',birthday:'birthday',seturl:'seturl',Position:'Position',position:'Position' }; const data = { ...legacyData(current) }; for (const [key,target] of Object.entries(mappings)) if (source[key] !== undefined && fields[target]) current.set(target,writeValue(source[key],fields[target])); for (const [key,target] of Object.entries(legacyMappings)) if (source[key] !== undefined) data[target] = typeof source[key] === 'string' ? cleanText(source[key],target === 'salt' || target === 'seturl' ? 1000 : 200) : source[key];
  800. const inviteCode = String(input.inviCode || '').trim(); if (inviteCode) { const existingParent = number(data.ParentUserID); const rows = await Psql.query('SELECT COALESCE("legacyUserId",("legacyUserData"->>\'UserID\')::numeric) AS id FROM "_User" WHERE "company"=$1 AND "username"=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1',[DEFAULT_COMPANY_ID,inviteCode]); const parentId = number(rows[0] && rows[0].id); if (!parentId) fail(400,'邀请码不存在'); if (parentId === ownId || await chainContains(parentId,ownId)) fail(400,'邀请关系不能形成循环'); if (existingParent && existingParent !== parentId) fail(409,'邀请人已完善,不能变更'); data.ParentUserID = parentId; }
  801. current.set('legacyUserData',data); await current.save(null,{useMasterKey:true}); return { result:legacyUser(current),addon:legacyUserAddon(current) };
  802. }
  803. function legacyUserRow(source) {
  804. const data = legacyData(source); const userId = number(firstValue(source.legacyUserId,source.userid,source.num,data.UserID)); const username = String(firstValue(source.username,data.UserName,'') || ''); const honeyName = String(firstValue(source.nickname,source.nickName,source.realName,data.HoneyName,username) || ''); const groupId = number(firstValue(source.legacyGroupId,data.GroupID)); const parentId = number(firstValue(data.ParentUserID,source.puid)); const avatar = String(firstValue(source.avatar,data.salt,'') || ''); const teamSize = number(firstValue(source.__teamSize,source.TeamSize)); const vip = number(firstValue(data.VIP,source.vip)); const regTime = firstValue(data.RegTime,source.createdAt); const row = { objectId:String(source.objectId || ''), userId, userName:username, honeyName, userFace:avatar, mobile:String(firstValue(source.mobile,source.phone,'') || ''), groupId, puid:parentId, vip, regTime, teamSize, email:String(firstValue(source.email,data.Email,'') || ''), realName:String(firstValue(source.realName,data.TrueName,'') || '') };
  805. return { ...row, UserID:userId, UserName:username, HoneyName:honeyName, UserFace:avatar, GroupID:groupId, ParentUserID:parentId, VIP:vip, RegTime:regTime, TeamSize:teamSize, Email:row.email, TrueName:row.realName, UserExp:number(data.UserExp), boffExp:number(data.boffExp), salt:avatar };
  806. }
  807. async function findByLegacyId(className, fields, legacyId, aliases) { const field = aliases.find((name) => fields[name]); if (!field) return null; const companyClause = fields.company ? ' AND "company" = $2' : ''; const values = fields.company ? [String(legacyId),DEFAULT_COMPANY_ID] : [String(legacyId)]; const rows = await Psql.query('SELECT "objectId" FROM "' + className + '" WHERE CAST("' + field + '" AS text) = $1' + companyClause + ' LIMIT 1', values); return rows[0] ? new Parse.Query(className).get(rows[0].objectId,{useMasterKey:true}) : null; }
  808. 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 } }; }
  809. const CONTENT_ADDONS = {
  810. 52: { className:'VocabularyWord', fields:['id','lj','sy','yb','yp'] },
  811. 53: { className:'PracticeRecord', fields:['id','kcid','scid','xxcs','yhid','jrscb'] },
  812. 54: { className:'CourseAppointment', fields:['id','pl','bxrq','dslx','dszt','fxpl','jffs','jssj','kcid','kssj','plxm','scsj','sdsd','szmd','szyh','yysj','yykcid'] },
  813. 56: { className:'DailyStudyRecord', fields:['id','pl','con','djq','ygg','dqrq','dsid','fxrl','xxqs','szmdid','userId','learned'] },
  814. 58: { className:'CourseBinding', fields:['id','yxx','cksl','kcid','syjd','yhid'] },
  815. 59: { className:'LessonRecord', fields:['id','pf','jffs','jsmz','kcid','kclx','kcmc','kzsj','pjnr','pldp','xymz','yyds','plpjsj','szmdid'] },
  816. 60: { className:'MemoryPracticeRecord', fields:['id','fxzt','kcid','plid','wcsj','yhid','kywrq','kywsj','xxjlid','orderId'] },
  817. 61: { className:'AssessmentProfile', fields:['id','df','askid','wrong','userId','answerid','dontKnow','prevScore','totalScore'] }
  818. };
  819. const NODE_MODELS = { 28:58, 29:54, 32:53, 291:56, 296:59, 327:52, 388:60, 389:61 };
  820. const PRIVATE_CONTENT_MODELS = new Set([53,54,56,58,59,60,61]);
  821. const CONTENT_WRITE_AUTH = {
  822. 53: { subjects:['yhid'], actors:[], tableName:'ZL_C_lxjl', nodeId:32 }, 54: { subjects:['szyh'], actors:['pl'], tableName:'ZL_C_order', nodeId:29 }, 56: { subjects:['userId'], actors:['pl'], tableName:'ZL_C_ss', nodeId:291 }, 58: { subjects:['yhid'], actors:[], tableName:'ZL_C_kcbd', nodeId:28 },
  823. 59: { subjects:['xymz'], actors:['jsmz'], tableName:'ZL_C_skjl', nodeId:296 }, 60: { subjects:['yhid'], actors:['plid'], tableName:'ZL_C_kywjl', nodeId:388 }, 61: { subjects:['userId'], actors:[], tableName:'ZL_C_cespj', nodeId:389 }
  824. };
  825. 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); }
  826. function requestedContentModel(input) { const nodes = String(input.nodeid || input.nid || input.nodes || '').split(',').map(number).filter(Boolean); return contentModel(input, nodes); }
  827. function legacyFilterPairs(input) {
  828. const source = [input.myfield, input.myfield2].filter(Boolean).join('|'); if (!source) return [];
  829. 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] }; });
  830. }
  831. function addonField(config, requested) { return config && config.fields.find((field) => field.toLowerCase() === String(requested).toLowerCase()); }
  832. function contentQueryRequiresAuth(action, input) { return (action === 'content_list' || action === 'content_list_llk') && (PRIVATE_CONTENT_MODELS.has(requestedContentModel(input)) || legacyFilterPairs(input).length > 0); }
  833. async function authorizeContentAccess(current, input) {
  834. 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));
  835. }
  836. 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'); }
  837. async function authorizeOwnerPairs(current, pairs) {
  838. if (isAdminUser(current)) return; const owners = pairs.filter((pair) => ['userid','yhid','xymz','jsmz','szyh'].includes(pair.name)); if (!owners.length) fail(403, '私有内容查询必须限定用户');
  839. 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;
  840. 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, '无权查看该学员的内容记录'); }
  841. }
  842. 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); }
  843. function contentOrder(input, config) {
  844. const raw = String(input.orders || '').trim(); if (!raw) return 'c."updatedAt" DESC'; if (/^NEWID\(\)$/i.test(raw)) return 'RANDOM()';
  845. 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();
  846. 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';
  847. }
  848. async function contentPage(input, publicOnly = false) {
  849. const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['c."company" = $1'];
  850. if (input.objectId) { values.push(String(input.objectId)); clauses.push('c."objectId" = $' + values.length); }
  851. if (input.generalId) { values.push(String(input.generalId)); clauses.push('CAST(c."generalId" AS text) = $' + values.length); }
  852. const nodeIds = String(input.nodeid || input.nid || input.nodes || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0');
  853. if (nodeIds.length) { values.push(nodeIds); clauses.push('CAST(c."nodeId" AS text) = ANY($' + values.length + '::text[])'); }
  854. 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); }
  855. if (publicOnly && !modelId) { values.push([...PRIVATE_CONTENT_MODELS].map(String)); clauses.push('CAST(c."modelId" AS text) <> ALL($' + values.length + '::text[])'); }
  856. const filters = legacyFilterPairs(input); if (filters.length && !config) fail(501, '该内容模型的 addon 联表尚未映射');
  857. 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); }
  858. if (input.skey) { values.push('%' + String(input.skey).replace(/[%_]/g, '') + '%'); clauses.push('c."title" ILIKE $' + values.length); }
  859. 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 ');
  860. 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]);
  861. 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);
  862. 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);
  863. return { rows: normalized, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
  864. }
  865. async function sqlPage(className, input, filters = []) {
  866. if (!['App','Node'].includes(className)) fail(400, '不允许查询该类'); const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['"company" = $1'];
  867. for (const filter of filters) { values.push(String(filter.value)); clauses.push('CAST("' + filter.field + '" AS text) = $' + values.length); }
  868. 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]);
  869. 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);
  870. 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 } };
  871. }
  872. async function nodePage(input) {
  873. const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['"company"=$1']; if (input.pid !== undefined && input.pid !== '') { values.push(String(number(input.pid))); clauses.push('CAST("parentId" AS text)=$' + values.length); } const where = clauses.join(' AND '); const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "Node" WHERE ' + where,values); const rows = await Psql.query('SELECT * FROM "Node" WHERE ' + where + ' ORDER BY CASE WHEN CAST("orderId" AS text) ~ \'^-?[0-9]+$\' THEN CAST("orderId" AS numeric) ELSE 0 END ASC, CASE WHEN CAST("nodeId" AS text) ~ \'^-?[0-9]+$\' THEN CAST("nodeId" AS numeric) ELSE 0 END ASC LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2),values.concat([paging.size,(paging.index - 1) * paging.size])); const total = number(countRow.total); return { rows:rows.filter(isVisible).map((row) => legacyAliases(row,'Node')),page:{itemCount:total,pageCount:Math.ceil(total / paging.size),pageIndex:paging.index,pageSize:paging.size} };
  874. }
  875. async function nodeUnitPage(input,current) {
  876. const uid = await authorizeRequestedUser(current,input.userId || input.uid); const paging = pageInput(input); const parentId = number(input.pid); if (!parentId) fail(400,'缺少课程栏目 ID'); const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "Node" WHERE "company"=$1 AND CAST("parentId" AS text)=$2',[DEFAULT_COMPANY_ID,String(parentId)]); const rows = await Psql.query('SELECT n.*,COALESCE(w.total,0)::int AS "total",COALESCE(l.learned,0)::int AS "yx_word" FROM "Node" n LEFT JOIN LATERAL (SELECT COUNT(*)::int AS total FROM "CommonModel" c WHERE c."company"=$1 AND CAST(c."modelId" AS text)=\'52\' AND CAST(c."nodeId" AS text)=CAST(n."nodeId" AS text)) w ON TRUE LEFT JOIN LATERAL (SELECT COUNT(DISTINCT CAST(p."scid" AS text))::int AS learned 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) AND CAST(c."nodeId" AS text)=CAST(n."nodeId" AS text) WHERE p."company"=$1 AND CAST(p."yhid" AS text)=$3 AND COALESCE(CAST(p."xxcs" AS numeric),0)>0) l ON TRUE WHERE n."company"=$1 AND CAST(n."parentId" AS text)=$2 ORDER BY CASE WHEN CAST(n."orderId" AS text) ~ \'^-?[0-9]+$\' THEN CAST(n."orderId" AS numeric) ELSE 0 END ASC,CASE WHEN CAST(n."nodeId" AS text) ~ \'^-?[0-9]+$\' THEN CAST(n."nodeId" AS numeric) ELSE 0 END ASC LIMIT $4 OFFSET $5',[DEFAULT_COMPANY_ID,String(parentId),String(uid),paging.size,(paging.index - 1) * paging.size]); const total = number(countRow.total); return { rows:rows.map((source) => { const row = legacyAliases(source,'Node'); row.total = number(source.total); row.yx_word = number(source.yx_word); row.process = row.total ? Math.round(row.yx_word / row.total * 100) : 0; return row; }),page:{itemCount:total,pageCount:Math.ceil(total / paging.size),pageIndex:paging.index,pageSize:paging.size} };
  877. }
  878. function legacyAppRow(source) { if (!source) return null; const row = legacyAliases(source,'App'); row.ver = number(firstValue(row.index,String(row.version || '').replace(/\D/g,''))); row.nver = String(row.version || ''); row.intro = String(firstValue(row.changelog,row.desc,'') || ''); row.path = String(firstValue(row.downUrl,row.apkUrl,'') || ''); row.size = null; row.sizeUnavailable = true; return row; }
  879. 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 []; } }
  880. 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))) }; }
  881. function ownLegacyId(current) { return number(current && (current.get('legacyUserId') || current.get('userid') || current.get('num'))); }
  882. async function legacyUserObject(legacyId) { const fields = await fieldsOf('_User'); return findByLegacyId('_User', fields, legacyId, ['legacyUserId','userid','num']); }
  883. async function chainContains(startId, expectedAncestorId) {
  884. let cursor = number(startId); const expected = number(expectedAncestorId); const visited = new Set();
  885. for (let depth = 0; cursor && depth < 64 && !visited.has(cursor); depth += 1) { if (cursor === expected) return true; visited.add(cursor); const item = await legacyUserObject(cursor); if (!item) return false; cursor = number(legacyData(item).ParentUserID); }
  886. return false;
  887. }
  888. async function authorizeTeamUser(current, requestedId) {
  889. const ownId = ownLegacyId(current); const targetId = number(requestedId || ownId); if (!targetId) fail(400, '缺少用户 ID'); if (isAdminUser(current) || targetId === ownId) return targetId;
  890. if (await chainContains(targetId, ownId)) return targetId; if (await chainContains(ownId, targetId)) return targetId; fail(403, '无权查看该团队成员');
  891. }
  892. function userSelect(alias = 'u') { return alias + '."objectId",' + alias + '."username",' + alias + '."email",' + alias + '."mobile",' + alias + '."phone",' + alias + '."avatar",' + alias + '."nickname",' + alias + '."nickName",' + alias + '."realName",' + alias + '."legacyUserId",' + alias + '."legacyGroupId",' + alias + '."legacyUserData",' + alias + '."createdAt"'; }
  893. async function teamUserPage(input, current) {
  894. const paging = pageInput(input); const parentId = number(input.puid || ownLegacyId(current)); if (!parentId && !isAdminUser(current)) fail(400, '团队列表必须指定上级用户'); if (parentId) await authorizeTeamUser(current,parentId);
  895. const values = [DEFAULT_COMPANY_ID]; const clauses = ['u."company" = $1','(u."isDeleted" IS NULL OR u."isDeleted" = FALSE)']; if (parentId) { values.push(String(parentId)); clauses.push('COALESCE(u."legacyUserData"->>\'ParentUserID\',\'0\') = $' + values.length); }
  896. const groups = String(input.gids || '').split(',').map(number).filter((value) => value > 0); if (groups.length) { values.push(groups.map(String)); clauses.push('COALESCE(CAST(u."legacyGroupId" AS text),u."legacyUserData"->>\'GroupID\',\'0\') = ANY($' + values.length + '::text[])'); }
  897. const where = clauses.join(' AND '); const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "_User" u WHERE ' + where, values); const rowValues = values.concat([paging.size,(paging.index - 1) * paging.size]); const order = groups.length ? 'CASE WHEN COALESCE(u."legacyUserData"->>\'UserExp\',\'0\') ~ \'^-?[0-9]+(?:\\.[0-9]+)?$\' THEN (u."legacyUserData"->>\'UserExp\')::numeric ELSE 0 END DESC,' : '';
  898. const rows = await Psql.query('SELECT ' + userSelect('u') + ',(SELECT COUNT(*)::int FROM "_User" child WHERE child."company" = $1 AND COALESCE(child."legacyUserData"->>\'ParentUserID\',\'0\') = COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\',\'0\')) AS "__teamSize" FROM "_User" u WHERE ' + where + ' ORDER BY ' + order + ' CASE WHEN COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\',\'0\') ~ \'^[0-9]+$\' THEN COALESCE(CAST(u."legacyUserId" AS text),u."legacyUserData"->>\'UserID\',\'0\')::numeric ELSE 0 END DESC LIMIT $' + (values.length + 1) + ' OFFSET $' + (values.length + 2), rowValues); const total = number(countRow.total);
  899. return { rows:rows.map(legacyUserRow), page:{ itemCount:total, pageCount:Math.ceil(total / paging.size), pageIndex:paging.index, pageSize:paging.size } };
  900. }
  901. async function teamStats(input,current) {
  902. const parentId = await authorizeTeamUser(current,input.uid); const rows = await Psql.query('SELECT COALESCE("legacyUserData"->>\'VIP\',\'0\') AS vip,COUNT(*)::int AS total FROM "_User" WHERE "company"=$1 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) AND COALESCE("legacyUserData"->>\'ParentUserID\',\'0\')=$2 GROUP BY COALESCE("legacyUserData"->>\'VIP\',\'0\')', [DEFAULT_COMPANY_ID,String(parentId)]); const result = { childs:0,v0:0,v1:0,v2:0,v3:0,v4:0,v5:0 }; for (const row of rows) { const total = number(row.total); result.childs += total; const key = 'v' + Math.max(0,Math.min(5,number(row.vip))); result[key] += total; } return result;
  903. }
  904. async function coachStudentPage(input,current) {
  905. const coachId = ownLegacyId(current); if (!coachId) fail(400,'陪练账号缺少旧系统用户 ID'); const paging = pageInput(input); const baseValues = [DEFAULT_COMPANY_ID,String(coachId)]; const base = 'FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=$1 AND CAST(a."id" AS text)=CAST(c."itemId" AS text) WHERE c."company"=$1 AND CAST(c."modelId" AS text)=\'54\' AND CAST(c."status" AS text)=\'99\' AND CAST(a."pl" AS text)=$2 AND COALESCE(CAST(a."szyh" AS text),\'\')<>\'\''; const countRow = await Psql.one('SELECT COUNT(DISTINCT CAST(a."szyh" AS text))::int AS total ' + base,baseValues); const rows = await Psql.query('WITH students AS (SELECT CAST(a."szyh" AS text) AS uid,MAX(CAST(a."id" AS text)) AS appointment_id ' + base + ' GROUP BY CAST(a."szyh" AS text) ORDER BY MAX(c."updatedAt") DESC LIMIT $3 OFFSET $4) SELECT ' + userSelect('u') + ',students.uid AS "__studentId",students.appointment_id AS "__appointmentId" FROM students LEFT JOIN "_User" u ON u."company"=$1 AND CAST(u."legacyUserId" AS text)=students.uid',baseValues.concat([paging.size,(paging.index - 1) * paging.size])); const normalized = rows.map((row) => { const value = legacyUserRow(row); const studentId = number(row.__studentId || value.UserID); return { ...value, userId:studentId, UserID:studentId, szyh:studentId, ID:number(row.__appointmentId), honeyname:value.HoneyName, honeyName:value.HoneyName }; }); const total = number(countRow.total); return { rows:normalized,page:{ itemCount:total,pageCount:Math.ceil(total / paging.size),pageIndex:paging.index,pageSize:paging.size } };
  906. }
  907. const POINT_HISTORY_CLASSES = { 1:'UserExpDomP',2:'UserSIcon',3:'UserExpHis',4:'UserUserPoint',5:'UserDummyPoint',6:'UserCredit' };
  908. function legacyPointRow(source) { const row = safe(source); row.ExpHisID = firstValue(row.expHisId,row.objectId); row.UserID = number(row.userId); row.HisTime = firstValue(row.hisTime,row.createdAt); row.Score = number(row.score); row.Detail = String(firstValue(row.detail,row.remark,'') || ''); row.Remark = String(row.remark || ''); row.score_before = number(row.scoreBefore); return row; }
  909. async function pointHistoryPage(input,current) {
  910. const stype = number(input.stype); const className = POINT_HISTORY_CLASSES[stype]; if (!className) fail(400,'虚拟币类型必须为 1-6'); const uid = await authorizeRequestedUser(current,input.uid); const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID,String(uid)]; const where = '"company"=$1 AND CAST("userId" AS text)=$2'; const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "' + className + '" WHERE ' + where,values); const rows = await Psql.query('SELECT * FROM "' + className + '" WHERE ' + where + ' ORDER BY COALESCE("hisTime","updatedAt","createdAt") DESC LIMIT $3 OFFSET $4',values.concat([paging.size,(paging.index - 1) * paging.size])); const total = number(countRow.total); return { rows:rows.filter(isVisible).map(legacyPointRow),page:{ itemCount:total,pageCount:Math.ceil(total / paging.size),pageIndex:paging.index,pageSize:paging.size },addon:stype === 1 ? { purse_fee:null,purseFeeRuleUnavailable:true } : {} };
  911. }
  912. async function createGuestbook(input,current) {
  913. const model = parseObject(input.model,'留言'); const uid = ownLegacyId(current); if (!uid) fail(400,'当前用户缺少旧系统用户 ID'); const requestedUid = number(firstValue(model.UserID,model.userid,input.UserID,input.userid,uid)); if (requestedUid !== uid && !isAdminUser(current)) fail(403,'不允许代替其他用户提交留言'); const name = cleanText(firstValue(input.name,model.Name,model.name,''),80); const title = cleanText(firstValue(model.Title,model.title,input.Title,input.title,name ? name + ' 反馈的意见' : '用户反馈'),200); const content = String(firstValue(model.TContent,model.tcontent,input.TContent,input.tcontent,input.content,'') || '').trim(); if (!content) fail(400,'请填写反馈内容'); if (content.length > 800) fail(400,'反馈内容不得超过 800 字'); const gid = Math.floor(100000000000000 + Math.random() * 800000000000000); const item = new Parse.Object('Guestbook'); item.set('company',current.get('company')); item.set('sourceKey','cloud:guestbook_add:' + uid + ':' + gid); item.set('gid',gid); item.set('gdate',new Date()); item.set('userid',requestedUid); item.set('title',title); item.set('tcontent',content); item.set('cateid',number(firstValue(model.Cateid,model.cateid,input.Cateid,input.cateid),22)); item.set('parentid',0); item.set('status',0); await item.save(null,{useMasterKey:true}); const result = safe(item); return { ...result,GID:gid,UserID:requestedUid,Title:title,TContent:content,Cateid:number(result.cateid),Status:0 };
  914. }
  915. async function authorizeRequestedUser(current, requestedId) {
  916. const targetId = number(requestedId || ownLegacyId(current)); if (!targetId) fail(400, '缺少用户 ID'); if (isAdminUser(current) || targetId === ownLegacyId(current)) return targetId;
  917. 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;
  918. }
  919. async function authorizeBusinessRow(current, row, subjectFields, actorFields = []) {
  920. 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;
  921. 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, '无权查看该业务记录');
  922. }
  923. function normalizeWordRow(source) { const addon = source.__addon || {}; const merged = { ...source, ...addon }; delete merged.__addon; return legacyAliases(merged, 'CommonModel'); }
  924. async function orderDetailRow(inputId, current, authorize = true) {
  925. 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]);
  926. 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 || '课程预约' }; }
  927. 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;
  928. }
  929. async function recordDetailData(input, current) {
  930. 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]);
  931. 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 = [];
  932. 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]);
  933. 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] }; });
  934. const order = recordAddon.dsid ? await orderDetailRow(recordAddon.dsid, current, false) : null; return { words, addon: order ? [order] : [], record };
  935. }
  936. async function newWordPage(input, current) {
  937. 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]));
  938. 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 } };
  939. }
  940. async function courseWordPage(input, current) {
  941. 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 } };
  942. }
  943. async function updateLearningWords(input, current) {
  944. 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);
  945. 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(','));
  946. 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;
  947. 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); }
  948. 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) };
  949. }
  950. 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 + '格式错误'); }
  951. function cleanText(value, max) { return String(value == null ? '' : value).trim().slice(0, max); }
  952. 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; }
  953. async function upsertStudyRecord(input, current) {
  954. 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) || '学习记录';
  955. 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;
  956. if (found) { record = await new Parse.Query('DailyStudyRecord').get(found.recordObjectId, { useMasterKey: true }); common = await new Parse.Query('CommonModel').get(found.commonObjectId, { useMasterKey: true }); }
  957. 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]))); } };
  958. 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 }; }
  959. 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 };
  960. }
  961. function reviewSchedule() {
  962. const shifted = new Date(Date.now() + 8 * 60 * 60 * 1000); const today = new Date(Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate())); const pad = (value) => String(value).padStart(2, '0');
  963. return [1,2,3,5,7,9,11,14,17,21,30,40,50,60,90].map((offset) => { const day = new Date(today.getTime() + offset * 86400000); return day.getUTCFullYear() + pad(day.getUTCMonth() + 1) + pad(day.getUTCDate()); }).join(',');
  964. }
  965. async function createStudyContent(input, current) {
  966. const content = parseObject(input.content, '内容'); const addon = parseObject(input.addon, '附表内容'); const modelId = number(content.ModelID ?? content.modelId); if (modelId !== 56) fail(400, 'content_add_zt 只支持每日学习记录 Model 56');
  967. const uid = await authorizeRequestedUser(current, addon.UserID ?? addon.userId); const words = addon.xxqs === undefined ? [] : await validateStudyWords(addon.xxqs); const actorId = ownLegacyId(current); const recordId = Math.floor(100000000000000 + Math.random() * 800000000000000); const generalId = recordId + 1; let record; let common;
  968. record = new Parse.Object('DailyStudyRecord'); record.set('company', current.get('company')); record.set('sourceKey', 'cloud:content_add_zt:' + uid + ':' + generalId); record.set('userId', uid); record.set('pl', actorId && actorId !== uid ? String(actorId) : '0'); record.set('fxrl', reviewSchedule()); record.set('xxqs', JSON.stringify(words)); record.set('learned', words.length || Math.max(0, number(addon.learned))); record.set('ygg', words.length ? words.filter((item) => item.check === 1).length : Math.max(0, number(addon.ygg))); record.set('djq', words.length ? words.filter((item) => item.check === 2).length : Math.max(0, number(addon.djq)));
  969. if (addon.con !== undefined) record.set('con', cleanText(addon.con, 1000)); if (addon.dqrq !== undefined) { const date = cleanText(addon.dqrq, 20); if (!/^\d{8}$/.test(date)) fail(400, '学习日期必须为 yyyymmdd'); record.set('dqrq', date); } if (addon.dsid !== undefined) record.set('dsid', cleanText(addon.dsid, 100)); if (addon.szmdid !== undefined) record.set('szmdid', cleanText(addon.szmdid, 100));
  970. 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:content_add_zt:' + uid + ':' + generalId); common.set('generalId', generalId); common.set('itemId', recordId); common.set('modelId', 56); common.set('nodeId', number(content.nodeId ?? content.NodeID, 291) || 291); common.set('tableName', 'ZL_C_ss'); common.set('title', cleanText(content.title ?? content.Title, 200) || cleanText(current.get('nickname') || current.get('username'), 200) || '学习记录'); common.set('inputer', cleanText(content.inputer ?? content.Inputer ?? current.get('username'), 100)); common.set('hits', number(content.Hits ?? content.hits, record.get('learned'))); common.set('status', number(content.Status ?? content.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; }
  971. return String(generalId);
  972. }
  973. function writeAddonField(config, requested) { const normalized = String(requested).replace(/[^A-Za-z0-9]/g, '').toLowerCase(); return config && config.fields.find((field) => field.replace(/[^A-Za-z0-9]/g, '').toLowerCase() === normalized); }
  974. function writeValue(value, definition) {
  975. if (!definition) return value; if (definition.type === 'Number') { const parsed = Number(value); if (!Number.isFinite(parsed)) fail(400, '数值字段格式错误'); return parsed; }
  976. if (definition.type === 'Boolean') return [true,1,'1','true','True','TRUE'].includes(value); if (definition.type === 'Date') { const date = new Date(value); if (Number.isNaN(date.getTime())) fail(400, '日期字段格式错误'); return date; }
  977. if (definition.type === 'String') return typeof value === 'string' ? value.slice(0, 50000) : JSON.stringify(value).slice(0, 50000); fail(400, '不支持写入字段类型 ' + definition.type);
  978. }
  979. function snapshotFields(object, fields) { const snapshot = {}; for (const field of fields) snapshot[field] = Object.prototype.hasOwnProperty.call(object.attributes || {}, field) ? { exists:true, value:object.get(field) } : { exists:false }; return snapshot; }
  980. function restoreFields(object, snapshot) { for (const [field,state] of Object.entries(snapshot)) { if (state.exists) object.set(field, state.value); else object.unset(field); } }
  981. async function updateContentPair(input, current) {
  982. const content = parseObject(input.content, '内容'); const addonInput = parseObject(input.addon, '附表内容'); const generalId = String(content.GeneralID ?? content.generalId ?? input.id ?? input.generalId ?? '').trim(); if (!generalId) fail(400, '缺少内容 GeneralID');
  983. const resolved = await resolveContent({ id: generalId }, true); const common = resolved.object; if (!common) fail(404, '指定内容不存在'); const modelId = number(common.get('modelId')); const config = CONTENT_ADDONS[modelId]; const auth = CONTENT_WRITE_AUTH[modelId]; if (!config || !auth) fail(501, '目标 Schema 未迁移内容模型 ' + modelId); if (modelId === 59 && number(common.get('nodeId')) !== 296) fail(501, 'Model 59 的非课次节点缺少独立目标 Schema');
  984. const addonRows = await Psql.query('SELECT "objectId" FROM "' + config.className + '" WHERE "company" = $1 AND CAST("id" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, String(common.get('itemId'))]); const addon = addonRows[0] ? await new Parse.Query(config.className).get(addonRows[0].objectId, { useMasterKey: true }) : null; if (!addon) fail(404, '内容附表不存在'); const existing = safe(addon); await authorizeBusinessRow(current, existing, auth.subjects, auth.actors);
  985. const addonSchema = await fieldsOf(config.className); const addonChanged = []; const addonSnapshot = {}; for (const [requested,value] of Object.entries(addonInput)) { if (String(requested).toLowerCase() === 'id') continue; const field = writeAddonField(config, requested); if (!field || !addonSchema[field] || ['id','company','sourceKey'].includes(field)) fail(400, '不允许更新附表字段 ' + requested); if (auth.subjects.concat(auth.actors).includes(field) && String(existing[field] ?? '') !== String(value ?? '')) fail(403, '不允许变更内容所属用户'); if (!addonSnapshot[field]) Object.assign(addonSnapshot, snapshotFields(addon, [field])); addon.set(field, writeValue(value, addonSchema[field])); addonChanged.push(field); }
  986. const commonSnapshot = snapshotFields(common, ['upDateTime','title','template','createTime']); const commonChanged = ['upDateTime']; common.set('upDateTime', new Date()); const title = content.Title ?? content.title; if (title !== undefined && String(title).trim()) { common.set('title', cleanText(title, 200)); commonChanged.push('title'); } const template = content.Template ?? content.template; if (template !== undefined && template !== null) { common.set('template', String(template) === '-100' ? '' : cleanText(template, 500)); commonChanged.push('template'); } const createTime = content.CreateTime ?? content.createTime; if (createTime) { const date = new Date(createTime); if (Number.isNaN(date.getTime()) || date.getUTCFullYear() <= 1970) fail(400, '内容创建时间格式错误'); common.set('createTime', date); commonChanged.push('createTime'); }
  987. if (!addonChanged.length && commonChanged.length === 1) fail(400, '没有可更新的内容字段'); try { await Parse.Object.saveAll([addon, common], { useMasterKey: true }); } catch (error) { restoreFields(addon, addonSnapshot); restoreFields(common, commonSnapshot); await Parse.Object.saveAll([addon, common], { useMasterKey: true }).catch(() => undefined); throw error; } return String(common.get('generalId'));
  988. }
  989. async function createContentPair(input, current) {
  990. const content = parseObject(input.content, '内容'); const addonInput = parseObject(input.addon, '附表内容'); const modelId = number(content.ModelID ?? content.modelId); const config = CONTENT_ADDONS[modelId]; const auth = CONTENT_WRITE_AUTH[modelId]; if (!config || !auth) fail(501, '目标 Schema 未迁移内容模型 ' + modelId); const nodeId = number(content.NodeID ?? content.nodeId, auth.nodeId); if (modelId === 59 && nodeId !== 296) fail(501, 'Model 59 的非课次节点缺少独立目标 Schema');
  991. const ownerField = auth.subjects[0]; const ownerRequested = Object.entries(addonInput).find(([key]) => writeAddonField(config, key) === ownerField); const ownerId = number(ownerRequested && ownerRequested[1]); if (!ownerId) fail(400, '附表缺少所属用户字段 ' + ownerField); await authorizeRequestedUser(current, ownerId); const actorId = ownLegacyId(current); const schema = await fieldsOf(config.className); const recordId = Math.floor(100000000000000 + Math.random() * 800000000000000); const generalId = recordId + 1; let addon; let common;
  992. addon = new Parse.Object(config.className); addon.set('company', current.get('company')); addon.set('sourceKey', 'cloud:content_add:' + ownerId + ':' + generalId); for (const [requested,value] of Object.entries(addonInput)) { if (String(requested).toLowerCase() === 'id') continue; const field = writeAddonField(config, requested); if (!field || !schema[field] || ['id','company','sourceKey'].includes(field)) fail(400, '不允许写入附表字段 ' + requested); if (auth.subjects.includes(field) && number(value) !== ownerId) fail(403, '不允许变更内容所属用户'); if (auth.actors.includes(field) && number(value) && !isAdminUser(current) && number(value) !== actorId) fail(403, '不允许冒用其他业务操作人'); addon.set(field, writeValue(value, schema[field])); }
  993. if (modelId === 56 && addonInput.xxqs !== undefined) { const words = await validateStudyWords(addonInput.xxqs); addon.set('xxqs', JSON.stringify(words)); addon.set('learned', words.length); addon.set('ygg', words.filter((item) => item.check === 1).length); addon.set('djq', words.filter((item) => item.check === 2).length); }
  994. await addon.save(null, { useMasterKey: true }); try { await Psql.none('UPDATE "' + config.className + '" SET "id" = $1 WHERE "objectId" = $2', [recordId, addon.id]); common = new Parse.Object('CommonModel'); common.set('company', current.get('company')); common.set('sourceKey', 'cloud:content_add:' + ownerId + ':' + generalId); common.set('generalId', generalId); common.set('itemId', recordId); common.set('modelId', modelId); common.set('nodeId', nodeId || auth.nodeId); common.set('tableName', auth.tableName); common.set('title', cleanText(content.Title ?? content.title, 200) || cleanText(current.get('nickname') || current.get('username'), 200) || '内容记录'); common.set('inputer', cleanText(content.Inputer ?? content.inputer ?? current.get('username'), 100)); common.set('hits', number(content.Hits ?? content.hits)); common.set('status', number(content.Status ?? content.status, 99)); if (content.Template ?? content.template) common.set('template', cleanText(content.Template ?? content.template, 500)); await common.save(null, { useMasterKey: true }); } catch (error) { if (common && common.id) await common.destroy({ useMasterKey: true }).catch(() => undefined); await addon.destroy({ useMasterKey: true }).catch(() => undefined); throw error; }
  995. return String(generalId);
  996. }
  997. function randomObjectId() { const chars = '0123456789abcdef'; let value = ''; for (let index = 0; index < 20; index += 1) value += chars[Math.floor(Math.random() * chars.length)]; return value; }
  998. function periodConfig(type) { return ({ 1:{ field:'Purse', ledger:'UserExpDomP', point:0.5 }, 2:{ field:'SilverCoin', ledger:'UserSIcon', point:1 }, 3:{ field:'UserExp', ledger:'UserExpHis', point:0 }, 4:{ field:'UserPoint', ledger:'UserUserPoint', point:0 } })[number(type)] || null; }
  999. async function deductOrderPeriod(student, uid, type, orderId, detail) {
  1000. const config = periodConfig(type); if (!config) fail(400, '不支持的课时类型'); const periodKey = 'cloud:e_order_update:' + orderId + ':period'; const pointKey = 'cloud:e_order_update:' + orderId + ':point'; const sql = 'WITH locked AS (SELECT "objectId", COALESCE(("legacyUserData"->>$2)::numeric,0) AS before, COALESCE(("legacyUserData"->>\'UserPoint\')::numeric,0) AS point_before FROM "_User" WHERE "objectId"=$1 FOR UPDATE), claim AS (INSERT INTO "' + config.ledger + '" ("objectId","createdAt","updatedAt","company","sourceKey","score","scoreBefore","userId","hisTime","operator","scoreType","detail","remark","operatorIp","type1","type2","type3","attach","extend") SELECT $5,NOW(),NOW(),$7,$3,-1,locked.before,$8,NOW(),2,$9,$10,\'API\',\'\',\'0\',\'\',\'\',\'\',\'\' FROM locked WHERE locked.before >= 1 ON CONFLICT ("sourceKey") DO NOTHING RETURNING "objectId"), updated AS (UPDATE "_User" u SET "legacyUserData"=CASE WHEN $11::numeric > 0 THEN jsonb_set(jsonb_set(COALESCE(u."legacyUserData",\'{}\'::jsonb),ARRAY[$2]::text[],to_jsonb((locked.before-1)::numeric),true),ARRAY[\'UserPoint\']::text[],to_jsonb(GREATEST(locked.point_before-$11::numeric,0)),true) ELSE jsonb_set(COALESCE(u."legacyUserData",\'{}\'::jsonb),ARRAY[$2]::text[],to_jsonb((locked.before-1)::numeric),true) END, "updatedAt"=NOW() FROM locked,claim WHERE u."objectId"=locked."objectId" RETURNING u."objectId"), point_log AS (INSERT INTO "UserUserPoint" ("objectId","createdAt","updatedAt","company","sourceKey","score","scoreBefore","userId","hisTime","operator","scoreType","detail","remark","operatorIp","type1","type2","type3","attach","extend") SELECT $6,NOW(),NOW(),$7,$4,-$11,GREATEST(locked.point_before-$11::numeric,0),$8,NOW(),2,$9,$10,\'API\',\'\',\'0\',\'\',\'\',\'\',\'\' FROM locked,claim WHERE $11::numeric > 0 ON CONFLICT ("sourceKey") DO NOTHING RETURNING "objectId") SELECT (SELECT COUNT(*)::int FROM claim) AS claimed,(SELECT before FROM locked) AS before,(SELECT point_before FROM locked) AS point_before'; const rows = await Psql.query(sql, [student.id,config.field,periodKey,pointKey,randomObjectId(),randomObjectId(),DEFAULT_COMPANY_ID,uid,number(type),detail,config.point]); const row = rows[0] || {}; if (row.before === undefined) fail(404, '学员不存在'); if (number(row.before) < 1 && !number(row.claimed)) fail(409, '课时不足'); return { claimed:number(row.claimed) === 1, config, periodKey, pointKey };
  1001. }
  1002. async function rollbackOrderPeriod(studentId, config, periodKey, pointKey) {
  1003. const sql = 'WITH period_deleted AS (DELETE FROM "' + config.ledger + '" WHERE "sourceKey"=$2 RETURNING 1), point_deleted AS (DELETE FROM "UserUserPoint" WHERE "sourceKey"=$3 RETURNING 1), updated AS (UPDATE "_User" u SET "legacyUserData"=CASE WHEN $5::numeric > 0 THEN jsonb_set(jsonb_set(COALESCE(u."legacyUserData",\'{}\'::jsonb),ARRAY[$4]::text[],to_jsonb((COALESCE((u."legacyUserData"->>$4)::numeric,0)+1)::numeric),true),ARRAY[\'UserPoint\']::text[],to_jsonb((COALESCE((u."legacyUserData"->>\'UserPoint\')::numeric,0)+$5::numeric)),true) ELSE jsonb_set(COALESCE(u."legacyUserData",\'{}\'::jsonb),ARRAY[$4]::text[],to_jsonb((COALESCE((u."legacyUserData"->>$4)::numeric,0)+1)::numeric),true) END,"updatedAt"=NOW() WHERE u."objectId"=$1 AND EXISTS(SELECT 1 FROM period_deleted) RETURNING 1) SELECT (SELECT COUNT(*)::int FROM updated) AS restored'; await Psql.query(sql, [studentId,periodKey,pointKey,config.field,config.point]);
  1004. }
  1005. async function memoryReviewsForOrder(orderId, order, current) {
  1006. const rows = await Psql.query('SELECT d.*,c."generalId" AS "recordGeneralId" FROM "DailyStudyRecord" d JOIN "CommonModel" c ON c."company"=$1 AND CAST(c."modelId" AS text)=\'56\' AND CAST(c."itemId" AS text)=CAST(d."id" AS text) WHERE d."company"=$1 AND CAST(d."userId" AS text)=$2 AND CAST(d."dsid" AS text)=$3 ORDER BY d."updatedAt" DESC LIMIT 1', [DEFAULT_COMPANY_ID,String(number(order.szyh)),String(orderId)]); const study = rows[0]; if (!study) return { created:[], count:0 }; const dates = [...new Set(String(study.fxrl || '').split(',').filter((date) => /^\d{8}$/.test(date)))].slice(0, 15); const created = [];
  1007. try { for (const date of dates) { const key = 'cloud:e_order_update:' + orderId + ':review:' + date; const existingRecordRows = await Psql.query('SELECT "objectId","id" FROM "MemoryPracticeRecord" WHERE "company"=$1 AND "sourceKey"=$2 LIMIT 1', [DEFAULT_COMPANY_ID,key]); let record = existingRecordRows[0] ? await new Parse.Query('MemoryPracticeRecord').get(existingRecordRows[0].objectId,{useMasterKey:true}) : null; const existingCommonRows = await Psql.query('SELECT "objectId" FROM "CommonModel" WHERE "company"=$1 AND "sourceKey"=$2 LIMIT 1', [DEFAULT_COMPANY_ID,key]); if (record && existingCommonRows[0]) continue; const recordId = record ? number(record.get('id')) : Math.floor(100000000000000 + Math.random() * 800000000000000); if (!record) { record = new Parse.Object('MemoryPracticeRecord'); record.set('company',current.get('company')); record.set('sourceKey',key); record.set('yhid',number(order.szyh)); record.set('plid',number(order.fxpl || order.pl)); record.set('orderId',number(orderId)); record.set('xxjlid',number(study.recordGeneralId)); record.set('kcid',number(order.kcid)); const iso = date.slice(0,4)+'-'+date.slice(4,6)+'-'+date.slice(6,8); record.set('kywrq',iso); record.set('kywsj',iso+' '+cleanText(order.sdsd,20)); await record.save(null,{useMasterKey:true}); await Psql.none('UPDATE "MemoryPracticeRecord" SET "id"=$1 WHERE "objectId"=$2',[recordId,record.id]); created.push({ record, common:null }); } if (!existingCommonRows[0]) { const common = new Parse.Object('CommonModel'); common.set('company',current.get('company')); common.set('sourceKey',key); common.set('generalId',recordId+1); common.set('itemId',recordId); common.set('modelId',60); common.set('nodeId',388); common.set('tableName','ZL_C_gywjl'); common.set('title',cleanText(order.HoneyName || order.Title,120)+'@21天抗遗忘@'+date.slice(0,4)+'-'+date.slice(4,6)+'-'+date.slice(6,8)); common.set('inputer',cleanText(current.get('nickname') || current.get('username'),100)); common.set('hits',0); common.set('status',99); await common.save(null,{useMasterKey:true}); const tracked = created.find((item) => item.record.id === record.id); if (tracked) tracked.common = common; else created.push({ record:null, common }); } } return { created, count:dates.length }; } catch (error) { for (const pair of created.reverse()) { if (pair.common) await pair.common.destroy({useMasterKey:true}).catch(() => undefined); if (pair.record) await pair.record.destroy({useMasterKey:true}).catch(() => undefined); } throw error; }
  1008. }
  1009. async function appointmentObject(orderId) { const rows = await Psql.query('SELECT a."objectId" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=$1 AND CAST(a."id" AS text)=CAST(c."itemId" 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,String(orderId)]); if (rows[0]) return new Parse.Query('CourseAppointment').get(rows[0].objectId,{useMasterKey:true}); if (/^[A-Za-z0-9_-]{10,40}$/.test(String(orderId))) return new Parse.Query('CourseAppointment').get(String(orderId),{useMasterKey:true}); return null; }
  1010. async function updateOrderStatus(input, current) {
  1011. const content = parseObject(input.content,'预约更新内容'); const orderId = String(content.GeneralID ?? content.generalId ?? input.orderId ?? input.id ?? '').trim(); if (!orderId) fail(400,'缺少预约 ID'); const status = number(input.status,-1); if (![10,11,20,30].includes(status)) fail(400,'不支持的预约状态'); const order = await orderDetailRow(orderId,current,false); if (!order) fail(404,'预约记录不存在'); const actorId = ownLegacyId(current); const studentId = number(order.szyh); const coachId = number(order.pl); const requiredActor = status === 20 ? studentId : coachId; if (!isAdminUser(current) && actorId !== requiredActor) fail(403,status === 20 ? '仅预约学员可以确认评价' : '仅预约陪练可以更新课程状态'); const previous = number(order.dszt); const allowed = ({10:[0,10],11:[10,11],20:[11,20],30:[11,20,30]})[status]; if (!allowed.includes(previous)) fail(409,'预约状态不能从 '+previous+' 更新为 '+status); if (previous === status) return { GeneralID:orderId,previousStatus:previous,status,unchanged:true };
  1012. const appointment = await appointmentObject(orderId); if (!appointment) fail(404,'预约附表不存在'); let reviews = {created:[],count:0}; let period = null; let student = null; try { if (status === 11) { reviews = await memoryReviewsForOrder(orderId,order,current); const fields = await fieldsOf('_User'); student = await findByLegacyId('_User',fields,studentId,['legacyUserId','userid','num']); if (!student) fail(404,'预约学员不存在'); period = await deductOrderPeriod(student,studentId,number(order.dslx),orderId,new Date().toISOString().slice(0,16).replace('T',' ')+'学习结束,订单:'+orderId); }
  1013. appointment.set('dszt',String(status)); const now = cleanText(content.UpDateTime ?? content.updateTime,30) || new Date().toISOString().slice(0,16).replace('T',' '); if (status === 10) appointment.set('kssj',now); if (status === 11) appointment.set('jssj',now); await appointment.save(null,{useMasterKey:true}); return { GeneralID:orderId,previousStatus:previous,status,unchanged:false,reviewCount:reviews.count,periodDeducted:Boolean(period && period.claimed) }; }
  1014. catch (error) { if (period && period.claimed && student) await rollbackOrderPeriod(student.id,period.config,period.periodKey,period.pointKey).catch(() => undefined); for (const pair of reviews.created.reverse()) { if (pair.common) await pair.common.destroy({useMasterKey:true}).catch(() => undefined); if (pair.record) await pair.record.destroy({useMasterKey:true}).catch(() => undefined); } throw error; }
  1015. }
  1016. async function orderStatistics(input, current) {
  1017. 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;
  1018. 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; }
  1019. 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; }
  1020. return { t30:String(t30), t60:String(t60), t_tiyan:String(tTiyan), t_shichang:String(duration), t_total:String(total), t_yongji:String(commission) };
  1021. }
  1022. 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); }
  1023. async function memoryPage(input, current) {
  1024. 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]);
  1025. 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 } };
  1026. }
  1027. async function resolveContent(input, requireOne = false) { const fields = await fieldsOf('CommonModel'); const id = String(input.id || input.gid || input.generalId || ''); if (!id) { const query = new Parse.Query('CommonModel'); tenant(query, fields); if (requireOne) fail(400, '缺少内容 ID'); return { query, fields }; } if (!/^\d+$/.test(id) && /^[A-Za-z0-9_-]{10,40}$/.test(id)) { const byObjectId = new Parse.Query('CommonModel'); tenant(byObjectId, fields); try { return { object: await byObjectId.get(id, { useMasterKey: true }), fields }; } catch (_) {} } const rows = await Psql.query('SELECT "objectId" FROM "CommonModel" WHERE "company" = $1 AND CAST("generalId" AS text) = $2 LIMIT 1', [DEFAULT_COMPANY_ID, id]); if (!rows[0]) return { object:null, fields }; return { object:await new Parse.Query('CommonModel').get(rows[0].objectId, { useMasterKey: true }), fields }; }
  1028. async function handler(request, response) {
  1029. try {
  1030. const input = inputOf(request); const action = String(input.action || ''); if (!action) return response.status(400).json(reject('缺少 action'));
  1031. if (action === 'migration_status') return response.json(envelope({ implemented: ['app_update','user_login_passwd','user_register','user_info_name','user_get','user_list','user_dept','e_user_list','user_update','content_list','content_list_llk','content_get','content_uphis','content_add','content_add_zt','content_update','node_list','node_get','e_add_words','e_ck_list','e_get_21list','e_order_detail','e_order_tongji','e_order_update_v2','e_record_detail','e_words_list','stu_record_update_v2','user_point_list','guestbook_add'], blocked: BLOCKED }));
  1032. if (BLOCKED[action]) return response.status(501).json(reject('migration_blocked: ' + BLOCKED[action]));
  1033. if (action === 'user_login_passwd') {
  1034. const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username || !password) return response.status(400).json(reject('请输入账号和密码'));
  1035. try { const loggedIn = await Parse.User.logIn(username, password); if (loggedIn.get('isDisabled') === true || loggedIn.get('isDeleted') === true) { await revokeUserSessions(loggedIn).catch(() => undefined); return response.status(403).json(reject('账号已停用')); } if (loggedIn.get('passwordResetRequired') === true) return response.status(403).json(reject('旧系统账号必须先重置 Parse 密码')); return response.json(envelope(legacyUser(loggedIn, true), { State: 1 })); } catch (_) { return response.status(401).json(reject('账号或密码错误')); }
  1036. }
  1037. if (action === 'user_register') { const registered = await registerUser(input); return response.json(envelope(registered.result,registered.addon)); }
  1038. 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('用户不存在')); }
  1039. const privateNodeProgress = action === 'node_list' && number(input.ifunit) === 1; const publicRead = PUBLIC_READ.has(action) && !contentQueryRequiresAuth(action, input) && !privateNodeProgress; const current = await currentUser(request, !publicRead); if (contentQueryRequiresAuth(action, input) && current) await authorizeContentAccess(current, input);
  1040. if (action === 'user_get') { const requestedId = number(input.uid || ownLegacyId(current)); let target = current; if (requestedId && requestedId !== ownLegacyId(current)) { await authorizeTeamUser(current,requestedId); target = await legacyUserObject(requestedId); if (!target) return response.status(404).json(reject('用户不存在')); } return response.json(envelope(legacyUser(target),legacyUserAddon(target))); }
  1041. if (action === 'user_list') { const result = await teamUserPage(input,current); return response.json(envelope(result.rows,undefined,result.page)); }
  1042. if (action === 'user_dept') return response.json(envelope(await teamStats(input,current)));
  1043. if (action === 'e_user_list') { const result = await coachStudentPage(input,current); return response.json(envelope(result.rows,undefined,result.page)); }
  1044. if (action === 'user_update') { const updated = await updateUserProfile(input,current); return response.json(envelope(updated.result,updated.addon)); }
  1045. if (action === 'app_update') { const result = await sqlPage('App', { page: 1, pageSize: 1 }); return response.json(envelope(legacyAppRow(result.rows[0]))); }
  1046. if (action === 'content_list' || action === 'content_list_llk') { const result = await contentPage(input, !current); return response.json(envelope(result.rows, undefined, result.page)); }
  1047. if (action === 'content_get') { const id = String(input.id || input.gid || input.generalId || ''); if (!id) return response.status(400).json(reject('缺少内容 ID')); const identity = /^\d+$/.test(id) ? { generalId: id } : { objectId: 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])); }
  1048. if (action === 'content_uphis') { const id = String(input.id || input.gid || input.generalId || ''); if (!id) return response.status(400).json(reject('缺少内容 ID')); const identity = /^\d+$/.test(id) ? { generalId:id } : { objectId:id }; const base = await contentPage({ page:1,pageSize:1,...identity }); const detail = base.rows[0]; if (!detail) return response.status(404).json(reject('内容不存在')); const detailModel = number(detail.modelId || detail.ModelID); if (PRIVATE_CONTENT_MODELS.has(detailModel)) { if (!current) return response.status(401).json(reject('该内容详情需要登录')); await authorizeContentDetail(current,detail); } const updated = await Psql.one('UPDATE "CommonModel" SET "hits"=COALESCE("hits",0)+1,"updatedAt"=CURRENT_TIMESTAMP WHERE "company"=$1 AND CAST("objectId" AS text)=$2 RETURNING "hits"',[DEFAULT_COMPANY_ID,String(detail.objectId)]); return response.json(envelope({ hits:number(updated.hits) })); }
  1049. if (action === 'content_add') return response.json(envelope(await createContentPair(input, current)));
  1050. if (action === 'content_update') return response.json(envelope(await updateContentPair(input, current)));
  1051. if (action === 'node_list') { const result = privateNodeProgress ? await nodeUnitPage(input,current) : await nodePage(input); return response.json(envelope(result.rows, undefined, result.page)); }
  1052. 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('栏目不存在')); }
  1053. const legacyId = ownLegacyId(current);
  1054. if (action === 'content_add_zt') return response.json(envelope(await createStudyContent(input, current)));
  1055. if (action === 'e_order_update_v2') return response.json(envelope(await updateOrderStatus(input,current)));
  1056. if (action === 'e_order_tongji') return response.json(envelope(await orderStatistics(input, current)));
  1057. if (action === 'stu_record_update_v2') return response.json(envelope(await upsertStudyRecord(input, current)));
  1058. if (action === 'e_add_words') return response.json(envelope(await updateLearningWords(input, current)));
  1059. if (action === 'e_ck_list') { const result = await courseWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
  1060. if (action === 'e_get_21list') { const result = await memoryPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
  1061. if (action === 'e_words_list') { const result = await newWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
  1062. if (action === 'e_order_detail') { const found = await orderDetailRow(input.id, current); return found ? response.json(envelope([found])) : response.status(404).json(reject('预约记录不存在')); }
  1063. 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('学习记录不存在')); }
  1064. if (action === 'user_point_list') { const result = await pointHistoryPage(input,current); return response.json(envelope(result.rows,result.addon,result.page)); }
  1065. if (action === 'guestbook_add') return response.json(envelope(await createGuestbook(input,current)));
  1066. return response.status(501).json(reject('migration_blocked: 尚未完成动作映射 ' + action));
  1067. } catch (error) { const status = Number(error.status || (error.code === 101 ? 404 : 500)); const message = status === 501 ? 'migration_blocked: ' + error.message : status >= 500 ? '云函数执行失败' : error.message; return response.status(status).json(reject(message)); }
  1068. }
  1069. `;
  1070. const definitions = [
  1071. { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode, params: [{ name: 'operation', type: 'String', required: true }] },
  1072. { 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 }] },
  1073. { name: 'cms.users.extended', desc: '替代 ZL_EX_UserView,返回安全用户扩展资料', path: 'xiaoshu/cms/users/extended', code: cmsReadCode('user-extended'), params: [{ name: 'userId', type: 'String', required: true }] },
  1074. { name: 'cms.users.platform', desc: '替代 ZL_User_PlatView', path: 'xiaoshu/cms/users/platform', code: cmsReadCode('user-platform'), params: [{ name: 'userId', type: 'String', required: true }] },
  1075. { name: 'cms.users.wechat', desc: '替代 ZL_User_WXView,过滤令牌与密钥', path: 'xiaoshu/cms/users/wechat', code: cmsReadCode('user-wechat'), params: [{ name: 'userId', type: 'String', required: true }] },
  1076. { 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 }] },
  1077. { 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 }] },
  1078. { name: 'cms.search', desc: '替代 ZL_SearchView,搜索规范化 CommonModel', path: 'xiaoshu/cms/search', code: cmsReadCode('search'), params: [{ name: 'keyword', type: 'String', required: true }] },
  1079. { name: 'xiaoshu.app.gateway', desc: '旧 WXAPP action 到 Parse 规范化类的兼容云函数;无法证明等价的动作返回 migration_blocked', path: 'xiaoshu/app/gateway', code: appGatewayCode, params: [{ name: 'action', type: 'String', required: true }] },
  1080. ];
  1081. function validateDefinition(definition) {
  1082. if (definition.path.startsWith('/')) throw new Error(`${definition.name}: 当前执行器的全局 path 不接受前导 /`);
  1083. const factory = new Function(`${definition.code}\nreturn typeof handler;`);
  1084. if (factory() !== 'function') throw new Error(`${definition.name}: 未定义 handler`);
  1085. }
  1086. async function parseRequest(path, init = {}) {
  1087. const response = await fetch(`${PARSE_URL}${path}`, {
  1088. ...init,
  1089. headers: {
  1090. 'X-Parse-Application-Id': APP_ID,
  1091. 'X-Parse-Master-Key': MASTER_KEY,
  1092. 'Content-Type': 'application/json',
  1093. ...(init.headers || {}),
  1094. },
  1095. });
  1096. const payload = await response.json();
  1097. if (!response.ok || payload.error) throw new Error(payload.error || `Parse 请求失败:${response.status}`);
  1098. return payload;
  1099. }
  1100. async function upsert(definition) {
  1101. const where = encodeURIComponent(JSON.stringify({ name: definition.name }));
  1102. const existing = await parseRequest(`/classes/Function?where=${where}&limit=1&keys=objectId`);
  1103. const body = {
  1104. name: definition.name,
  1105. desc: definition.desc,
  1106. type: 'standalone',
  1107. path: definition.path,
  1108. code: definition.code.trim(),
  1109. params: definition.params,
  1110. paramList: definition.params,
  1111. respType: 'json',
  1112. respJson: { success: true, data: {} },
  1113. enabled: true,
  1114. version: '1.1.2',
  1115. };
  1116. const objectId = existing.results?.[0]?.objectId;
  1117. if (objectId) {
  1118. await parseRequest(`/classes/Function/${objectId}`, { method: 'PUT', body: JSON.stringify(body) });
  1119. return { action: 'updated', objectId };
  1120. }
  1121. const created = await parseRequest('/classes/Function', { method: 'POST', body: JSON.stringify(body) });
  1122. return { action: 'created', objectId: created.objectId };
  1123. }
  1124. for (const definition of definitions) validateDefinition(definition);
  1125. if (validateOnly) {
  1126. console.log(`Validated ${definitions.length} cloud function definitions.`);
  1127. } else {
  1128. if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY;不会把 masterKey 写入项目文件。');
  1129. for (const definition of definitions) {
  1130. const result = await upsert(definition);
  1131. console.log(`${result.action.padEnd(7)} ${definition.path} (${result.objectId})`);
  1132. }
  1133. }