deploy-admin-functions.mjs 422 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516
  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: '模型字段', PublishNode: '报刊发布节点',
  11. ExamClass: '试题分类', ExamSysQuestions: '考试题库', ExamSysPapers: '考试试卷', ExamType: '考试类型', ExamPoint: '知识点', PaperQuestions: '试卷大题分组',
  12. Datadiccategory: '数据字典分类', Datadic: '数据字典项',
  13. GradeCate: '多级字典分类', Grade: '多级字典选项',
  14. Currency: '货币管理', SysHoliday: '节假日管理',
  15. Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '商城运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
  16. GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科审核', Pub: '互动模块定义', PubTw: '提问互动提交', PubWTHD: '问题回答提交', PubZXDC: '在线调查提交', Role: '系统角色', ARoleAuth: '角色权限', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', DesignAsk: '问卷调查', DesignQuestion: '问卷题目', DesignAnswer: '问卷答卷', DesignRes: '可视化设计资源', ServiceSeat: '客服席位', Temp: '客服欢迎语', StoreApplication: '店铺管理', StoreStyle: '店铺样式', PageStyle: '黄页样式', PlatComp: '协同办公企业', ContentTagKey: '内容标签词库', UserLevel: '积分等级', UserMoneyLog: '资金积分流水', UserPromotion: '用户推广关系', CRMSAttr: 'CRM 客户类型', SysLog: '系统日志', MisType: 'OA 流程类型', Agency: '代理机构', DeliveryCenter: '交付中心'
  17. };
  18. const ALLOWED_CLASSES = new Set([
  19. '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','UserMoneyLog','UserPromotion','UserSIcon','UserUserPoint','UserExpDomP','UserExpHis'
  20. ]);
  21. const READ_ONLY_CLASSES = new Set(['_Role','Permission','ARoleAuth','PayPlat','ThirdPlatInfo','GuestBar','DesignAnswer','Baike','ExamSysPapers','ExamSysQuestions','PaperQuestions','ExamType','ContentPublish','PublishNode','SysLog','Pub','PubTw','PubWTHD','PubZXDC']);
  22. 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','legacyUserPlat','groupId']);
  23. const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
  24. const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
  25. const CLASS_SYSTEM_FIELDS = {
  26. CommonModel: new Set(['generalId','modelId','nodeId','itemId','tableName','status','isDeleted','sourceKey','orderId']),
  27. Node: new Set(['nodeId','parentId','depth','child','orderId','zstatus','sourceKey','cuser','cuname','editDate']),
  28. Special: new Set(['specId','pid','orderId','sourceKey','cuser','editDate']),
  29. Model: new Set(['modelId','modelType','tableName','sourceKey','nodeId','fromModel','multiFlag','sysModel']),
  30. ModelField: new Set(['fieldId','modelId','fieldName','fieldType','sourceKey','sysType','orderId']),
  31. Guestbook: new Set(['gid','parentid','cateid','userid','status','sourceKey','gdate','ip']),
  32. Guestcate: new Set(['cateid','parentId','gtype','sourceKey','orderId']),
  33. GuestBar: new Set(['id','cateId','pid','replyId','replyUserId','cuser','cuname','cdate','status','sourceKey','orderFlag','postFlag']),
  34. ExamClass: new Set(['cId','cClassid','cOrderBy','cClassType','sourceKey']),
  35. ExamPoint: new Set(['id','tid','orderBy','addUser','addTime','sourceKey']),
  36. QuestionsKnowledge: new Set(['kId','kClassId','kOrderBy','pid','cuser','cdate','sourceKey']),
  37. ExTeacher: new Set(['id','tclsss','addUser','creatTime','sourceKey']),
  38. Datadiccategory: new Set(['diccateid','sourceKey']),
  39. Datadic: new Set(['dicid','diccate','sourceKey']),
  40. GradeCate: new Set(['cateId','sourceKey']),
  41. Grade: new Set(['gradeId','cate','parentId','grade','sourceKey']),
  42. Currency: new Set(['id','sourceKey']),
  43. SysHoliday: new Set(['id','cdate','cadminId','cuserId','sourceKey']),
  44. Search: new Set(['id','type','state','time','adminId','orderId','linkType','linkState','sourceKey']),
  45. AdZone: new Set(['id','cdate','cadmin','image','sourceKey']),
  46. AdInfo: new Set(['id','cdate','zoneId','ztype','extend1','extend2','extend3','sourceKey']),
  47. FontPicShape: new Set(['id','shape','typeId','userId','userName','sourceKey','createTime','updateTime']),
  48. FontPicShapeType: new Set(['id','sourceKey','createTime','updateTime']),
  49. DesignAsk: new Set(['id','cdate','cuser','adminId','sourceKey']),
  50. DesignQuestion: new Set(['id','askId','cdate','cuser','orderId','sourceKey']),
  51. DesignAnswer: new Set(['id','askId','ip','cdate','userId','sourceKey']),
  52. DesignRes: new Set(['id','vpath','previewimg','cdate','userid','sourceKey']),
  53. Role: new Set(['roleId','ztype','zstatus','nodeId','auth','auth2','auth3','cadminId','cdate','sourceKey']),
  54. ARoleAuth: new Set(['id','rid','adminId','sourceKey']),
  55. ServiceSeat: new Set(['sId','sAdminId','sRemrk','sDateTime','sourceKey']),
  56. Temp: new Set(['id','useType','str3','str5','str6','describe','cdate','userId','sourceKey']),
  57. StoreApplication: new Set(['id','userId','userName','addTime','storeState','storeCommendState','storeModelId','sourceKey']),
  58. StoreStyle: new Set(['id','cdate','zstatus','sourceKey']),
  59. UserLevel: new Set(['id','cdate','ztype','addon1','addon2','addon3','addon4','addon5','orderId','storeId','zstatus','sourceKey','discountRate']),
  60. CRMSAttr: new Set(['id','ztype','cdate','zstatus','value2','value3','sourceKey']),
  61. ShopFareTlp: new Set(['id','cdate','userId','adminId','isFree','regionBan','ems','mail','sourceKey']),
  62. MisType: new Set(['id','createTime','sourceKey']),
  63. PageStyle: new Set(['pageNodeid','addtime','sourceKey']),
  64. PlatComp: new Set(['id','createUser','status','uppath','sourceKey'])
  65. };
  66. const USER_MONEY_LOG_CLASSES = { 1:'UserExpDomP', 2:'UserSIcon', 3:'UserExpHis', 4:'UserUserPoint', 5:'UserDummyPoint', 6:'UserCredit' };
  67. const USER_MONEY_LOG_LABELS = { 1:'余额', 2:'银币', 3:'经验', 4:'积分', 5:'虚拟币', 6:'信用分' };
  68. function inputOf(request) {
  69. const body = request.body || {};
  70. return body.params && typeof body.params === 'object' ? body.params : body;
  71. }
  72. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  73. function pointerId(value) { return value && (value.id || value.objectId || (value.__type === 'Pointer' && value.objectId)); }
  74. function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
  75. function safeValue(value, depth = 0) {
  76. if (value == null || depth > 4) return value;
  77. if (Array.isArray(value)) return value.map((item) => safeValue(item, depth + 1));
  78. if (value instanceof Date) return value.toISOString();
  79. if (value && typeof value === 'object' && ['Object','Pointer'].includes(value.__type) && value.className && value.objectId) return { __type: 'Pointer', className: String(value.className), objectId: String(value.objectId) };
  80. if (value && typeof value.toJSON === 'function') return safeValue(value.toJSON(), depth + 1);
  81. if (typeof value === 'object') {
  82. const output = {};
  83. for (const [key, item] of Object.entries(value)) {
  84. if (HIDDEN_FIELDS.has(key) || /(?:password|secret|token|masterkey|privatekey|mch_key|^ak$|^sk$)/i.test(key)) continue;
  85. output[key] = safeValue(item, depth + 1);
  86. }
  87. return output;
  88. }
  89. return value;
  90. }
  91. function normalizeContentTags(value) {
  92. const source = Array.isArray(value) ? value : String(value == null ? '' : value).split(/[|\r\n]+/);
  93. if (source.length > 1000) fail(400, '标签数量不能超过 1000 个');
  94. const tags = source.map((item) => String(item == null ? '' : item).replace(/[\s"\\]/g, '')).filter(Boolean);
  95. if (tags.some((tag) => tag.length > 100 || /[\u0000-\u001f\u007f]/.test(tag))) fail(400, '单个标签长度不能超过 100 个字符且不能包含控制字符');
  96. return tags;
  97. }
  98. async function readContentTags() {
  99. const config = await Parse.Config.get({ useMasterKey: true });
  100. return normalizeContentTags(config.get('legacyContentTags') || []);
  101. }
  102. 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); }
  103. async function requireAdmin(request) {
  104. const current = request.user || (typeof user !== 'undefined' ? user : null);
  105. if (!current) fail(401, '管理员会话已失效');
  106. await current.fetch({ useMasterKey: true });
  107. if (current.get('isDisabled') === true || current.get('isDeleted') === true) fail(403, '管理员账号已停用');
  108. const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
  109. const role = String(current.get('role') || '');
  110. const roleKey = String(current.get('adminRoleKey') || '');
  111. const isSuperAdmin = roleKey === 'super-admin' || roles.includes('super-admin');
  112. const isAdmin = current.get('isAdmin') === true || role === 'admin' || roles.includes('admin') || isSuperAdmin;
  113. if (!isAdmin) fail(403, '当前账号未被授权为后台管理员');
  114. const company = request.company || current.get('company') || null;
  115. if (!company && !isSuperAdmin) fail(403, '管理员账号尚未分配帐套');
  116. return { current, roles, isSuperAdmin, company };
  117. }
  118. function isAdminAccount(target) {
  119. const roles = Array.isArray(target.get('roles')) ? target.get('roles').map(String) : [];
  120. return target.get('isAdmin') === true || target.get('role') === 'admin' || roles.includes('admin') || target.get('adminRoleKey') === 'super-admin' || roles.includes('super-admin');
  121. }
  122. function assertCanManageUser(context, target, action) {
  123. if (action === 'lock' && target.id === context.current.id) fail(400, '不能停用当前登录账号');
  124. if (isAdminAccount(target) && target.id !== context.current.id && !context.isSuperAdmin) fail(403, '只有超级管理员可以管理其他管理员账号');
  125. }
  126. async function revokeSessions(targets) {
  127. let revoked = 0;
  128. while (targets.length) {
  129. const query = new Parse.Query('_Session'); query.containedIn('user', targets); query.limit(1000);
  130. const sessions = await query.find({ useMasterKey: true });
  131. if (!sessions.length) break;
  132. await Parse.Object.destroyAll(sessions, { useMasterKey: true }); revoked += sessions.length;
  133. if (sessions.length < 1000) break;
  134. }
  135. return revoked;
  136. }
  137. function assertClass(className) {
  138. if (!ALLOWED_CLASSES.has(className)) fail(400, '不允许访问该数据类');
  139. }
  140. function isSystemField(className, name) { return SYSTEM_FIELDS.has(name) || Boolean(CLASS_SYSTEM_FIELDS[className] && CLASS_SYSTEM_FIELDS[className].has(name)); }
  141. async function schemaFor(className) {
  142. assertClass(className);
  143. const schema = await new Parse.Schema(className).get({ useMasterKey: true });
  144. return schema && schema.fields ? schema.fields : {};
  145. }
  146. function applyTenant(query, fields, context, requestedCompanyId) {
  147. if (!fields.company) return;
  148. if (context.isSuperAdmin && requestedCompanyId) {
  149. query.equalTo('company', Parse.Object.createWithoutData('Company', String(requestedCompanyId)));
  150. } else if (context.company) {
  151. query.equalTo('company', context.company);
  152. }
  153. }
  154. function serializeObject(object) { return safeValue(object.toJSON()); }
  155. function toParseValue(field, value) {
  156. if (value == null || value === '') return value;
  157. if (field.type === 'Pointer') {
  158. const objectId = pointerId(value) || value;
  159. return Parse.Object.createWithoutData(field.targetClass, String(objectId));
  160. }
  161. if (field.type === 'Date') {
  162. const date = new Date(value.iso || value);
  163. if (Number.isNaN(date.getTime())) fail(400, '日期字段格式无效');
  164. return date;
  165. }
  166. if (field.type === 'Number') {
  167. const number = Number(value);
  168. if (!Number.isFinite(number)) fail(400, '数字字段格式无效');
  169. return number;
  170. }
  171. if (field.type === 'Boolean') return value === true || value === 'true';
  172. if (field.type === 'Array') { if (!Array.isArray(value)) fail(400, '数组字段格式无效'); return value; }
  173. if (field.type === 'Object') { if (!value || typeof value !== 'object' || Array.isArray(value)) fail(400, '对象字段格式无效'); return value; }
  174. if (!GENERIC_WRITE_TYPES.has(field.type)) fail(400, '该字段类型不允许通用编辑: ' + field.type);
  175. return value;
  176. }
  177. async function countClass(className, context) {
  178. const fields = await schemaFor(className);
  179. const query = new Parse.Query(className);
  180. applyTenant(query, fields, context);
  181. if (fields.isDeleted) query.notEqualTo('isDeleted', true);
  182. return query.count({ useMasterKey: true });
  183. }
  184. async function audit(context, action, className, objectId) {
  185. try {
  186. const log = new Parse.Object('SysLog');
  187. const schema = await new Parse.Schema('SysLog').get({ useMasterKey: true });
  188. const fields = schema.fields || {};
  189. if (fields.company && context.company) log.set('company', context.company);
  190. if (fields.userId) log.set('userId', context.current.id);
  191. if (fields.userName) log.set('userName', String(context.current.get('username') || ''));
  192. if (fields.remind) log.set('remind', '[AngularAdmin] ' + action + ' ' + className + '/' + objectId);
  193. if (fields.logType) log.set('logType', 'angular-admin');
  194. await log.save(null, { useMasterKey: true });
  195. } catch (_) { /* 审计表字段来自旧系统,失败不能覆盖主操作结果。 */ }
  196. }
  197. async function handler(request, response) {
  198. try {
  199. const input = inputOf(request);
  200. const operation = String(input.operation || 'meta');
  201. const context = await requireAdmin(request);
  202. if (operation === 'meta') {
  203. const functionQuery = new Parse.Query('Function');
  204. functionQuery.notEqualTo('enabled', false);
  205. const cloudFunctions = await functionQuery.count({ useMasterKey: true });
  206. return response.json({ success: true, data: {
  207. 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 },
  208. classes: ALLOWED_CLASSES.size, cloudFunctions
  209. }});
  210. }
  211. if (operation === 'logout') {
  212. const sessionToken = String(input.sessionToken || '');
  213. if (!sessionToken || sessionToken.length > 512) fail(400, '缺少有效的管理员会话');
  214. const query = new Parse.Query('_Session'); query.equalTo('sessionToken', sessionToken); query.equalTo('user', context.current); query.limit(1);
  215. const session = await query.first({ useMasterKey: true });
  216. if (session) await session.destroy({ useMasterKey: true });
  217. return response.json({ success: true, data: { revoked: Boolean(session) } });
  218. }
  219. if (operation === 'changeOwnPassword') {
  220. const oldPassword = typeof input.oldPassword === 'string' ? input.oldPassword : ''; const newPassword = typeof input.newPassword === 'string' ? input.newPassword : '';
  221. if (!oldPassword || newPassword.length < 8 || newPassword.length > 72) fail(400, '原密码不能为空,新密码长度应为 8 至 72 位'); if (oldPassword === newPassword) fail(400, '新密码不能与原密码相同');
  222. let verified; try { verified = await Parse.User.logIn(String(context.current.get('username') || ''), oldPassword); } catch (_) { fail(400, '原密码错误'); } if (!verified || verified.id !== context.current.id) fail(400, '原密码验证失败');
  223. const activeSessionQuery = new Parse.Query('_Session'); activeSessionQuery.equalTo('user', context.current); const activeSessions = await activeSessionQuery.count({ useMasterKey: true }); context.current.setPassword(newPassword); await context.current.save(null, { useMasterKey: true }); const explicitlyRevoked = await revokeSessions([context.current]); await audit(context, 'change-own-password', '_User', context.current.id); return response.json({ success: true, data: { objectId: context.current.id, revokedSessions: Math.max(activeSessions, explicitlyRevoked), reauthenticationRequired: true } });
  224. }
  225. if (operation === 'dashboard') {
  226. const metrics = [
  227. ['_User','用户总数'],['CourseBinding','课程绑定'],['CourseAppointment','课程预约'],['PracticeRecord','练习记录'],['LessonRecord','上课记录'],['DailyStudyRecord','每日学习'],['VocabularyWord','词库单词'],['CommonModel','内容记录']
  228. ];
  229. const counts = await Promise.all(metrics.map(async ([className, label]) => ({ className, label, count: await countClass(className, context), route: '/admin/resources/' + className })));
  230. const functionQuery = new Parse.Query('Function'); functionQuery.notEqualTo('enabled', false);
  231. let registeredRecords = 195452;
  232. try { const row = await Psql.oneOrNone('SELECT COUNT(*)::int AS count FROM cms.record_registry'); if (row) registeredRecords = Number(row.count); } catch (_) {}
  233. 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 }) } } });
  234. }
  235. if (operation === 'catalog') {
  236. const resources = [...ALLOWED_CLASSES].sort().map((className) => ({ className, label: CLASS_LABELS[className] || className, writable: !READ_ONLY_CLASSES.has(className), route: '/admin/resources/' + className }));
  237. return response.json({ success: true, data: { total: resources.length, resources } });
  238. }
  239. if (operation === 'apiCatalog') {
  240. const query = new Parse.Query('Function'); query.ascending('path'); query.limit(1000); const rows = await query.find({ useMasterKey: true });
  241. const results = rows.filter((entry) => entry.get('isDeleted') !== true && String(entry.get('path') || '').startsWith('xiaoshu/')).map((entry) => ({ objectId: entry.id, name: String(entry.get('name') || ''), desc: String(entry.get('desc') || ''), path: String(entry.get('path') || ''), type: String(entry.get('type') || 'standalone'), paramList: Array.isArray(entry.get('paramList')) ? safeValue(entry.get('paramList')) : [], respType: String(entry.get('respType') || 'json'), updatedAt: safeValue(entry.updatedAt) }));
  242. return response.json({ success: true, data: { total: results.length, results } });
  243. }
  244. if (operation === 'legacyConfigBlocker') {
  245. const action = String(input.action || ''); const blockers = {
  246. APIInfo_Submit: '旧 API_ID/API_Key/API_AdminKey、IP 访问策略与目标 sessionToken/云函数权限模型不等价,且没有能对全部函数生效的站点配置层',
  247. APIInfo_SwaggerClose: '目标云函数引擎没有帐套级 Swagger 实时调试关闭开关',
  248. APIInfo_SwaggerOpen: '目标云函数引擎没有帐套级 Swagger 实时调试开启开关',
  249. LicenceFile_API: '目标 Schema 缺少许可、隐私、协议与版权文档存储,云函数也不能写旧 wwwroot/Config 文件',
  250. close_system: '云函数运行时不存在可通过 app_offline.htm 关闭的单体 ASP.NET 进程',
  251. Hotkey: '目标 Schema 没有 KeyboardConfig/Hotkey 持久化模型,旧 XML 配置文件也不在云函数运行时',
  252. HotkeyAdd: '目标 Schema 没有 KeyboardConfig/Hotkey 持久化模型,无法生成可全局生效的快捷键',
  253. HotkeyAdd_Submit: '目标 Schema 没有 KeyboardConfig/Hotkey 持久化模型,不能伪造保存成功',
  254. Hotkey_API: '目标 Schema 没有 KeyboardConfig/Hotkey 持久化模型,启停、删除、重载和脚本匹配无可操作对象',
  255. Prize: '目标 Schema 没有 PrizeConfig/PrizeTask 配置模型及签到、任务规则数据',
  256. Prize_Submit: '目标 Schema 没有 PrizeConfig/PrizeTask 配置模型,无法持久化奖励积分、重复周期和过期规则'
  257. };
  258. if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧配置动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  259. }
  260. if (operation === 'createUser') {
  261. const username = String(input.username || '').trim();
  262. const password = typeof input.password === 'string' ? input.password : '';
  263. const displayName = String(input.displayName || '').trim();
  264. const mobile = String(input.mobile || '').trim();
  265. const inviteUsername = String(input.inviteUsername || '').trim();
  266. if (username.length < 3 || username.length > 64) fail(400, '账号长度应为 3 至 64 位');
  267. if (password.length < 8 || password.length > 72) fail(400, '密码长度应为 8 至 72 位');
  268. if (displayName.length > 100) fail(400, '显示名不能超过 100 位');
  269. if (mobile.length > 32) fail(400, '手机号不能超过 32 位');
  270. let companyId = pointerId(context.company);
  271. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  272. if (!companyId) fail(400, '开户必须指定帐套');
  273. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  274. const userFields = await schemaFor('_User');
  275. if (!userFields.company) fail(500, '用户表缺少帐套字段');
  276. 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]);
  277. if (duplicate) fail(409, '此用户名已被使用');
  278. let parentId = 0;
  279. if (inviteUsername) {
  280. 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]);
  281. parentId = Number(inviter && inviter.id) || 0;
  282. if (!parentId) fail(400, '邀请账号不存在');
  283. }
  284. const created = new Parse.User();
  285. created.setUsername(username); created.setPassword(password); created.set('company', company);
  286. if (userFields.type) created.set('type', 'user');
  287. if (userFields.isDisabled) created.set('isDisabled', false);
  288. if (userFields.isDeleted) created.set('isDeleted', false);
  289. if (userFields.legacyGroupId) created.set('legacyGroupId', 1);
  290. if (userFields.nickname) created.set('nickname', displayName || username);
  291. if (mobile && userFields.mobile) created.set('mobile', mobile);
  292. if (userFields.isAdmin) created.set('isAdmin', false);
  293. if (userFields.roles) created.set('roles', []);
  294. try { await created.signUp(null, { useMasterKey: true }); }
  295. catch (error) { if (Number(error && error.code) === 202) fail(409, '此用户名已被使用'); throw error; }
  296. try {
  297. const regTime = new Date().toISOString();
  298. 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 };
  299. 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)]);
  300. const legacyUserId = Number(rows[0] && rows[0].id) || 0;
  301. if (!legacyUserId) throw new Error('无法分配旧系统用户 ID');
  302. await created.fetch({ useMasterKey: true });
  303. await audit({ ...context, company }, 'create-user', '_User', created.id);
  304. return response.json({ success: true, data: { ...serializeObject(created), userId: legacyUserId, groupId: 1 } });
  305. } catch (error) {
  306. await created.destroy({ useMasterKey: true }).catch(() => undefined);
  307. throw error;
  308. }
  309. }
  310. if (operation === 'userBatch') {
  311. const action = String(input.action || '');
  312. if (!['lock','unlock','move'].includes(action)) fail(400, '不支持的用户批量操作');
  313. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  314. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  315. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个用户');
  316. const userFields = await schemaFor('_User');
  317. const query = new Parse.Query('_User'); applyTenant(query, userFields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  318. const targets = await query.find({ useMasterKey: true });
  319. if (targets.length !== objectIds.length) fail(404, '部分用户不存在或不属于当前帐套');
  320. for (const target of targets) assertCanManageUser(context, target, action);
  321. let group = null;
  322. let groupId = 0;
  323. if (action === 'move') {
  324. groupId = Number(input.groupId);
  325. if (!Number.isInteger(groupId) || groupId < 1) fail(400, '请选择有效用户组');
  326. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  327. if (companyIds.length !== 1) fail(400, '批量移动的用户必须属于同一帐套');
  328. const groupRow = await Psql.oneOrNone('SELECT "objectId" FROM "Group" WHERE "company"=$1 AND "groupId"=$2 LIMIT 1', [companyIds[0], groupId]);
  329. if (!groupRow) fail(404, '目标用户组不存在或不属于当前帐套');
  330. group = await new Parse.Query('Group').get(String(groupRow.objectId), { useMasterKey: true });
  331. }
  332. const now = new Date();
  333. for (const target of targets) {
  334. const legacyData = { ...(target.get('legacyUserData') || {}) };
  335. if (action === 'move') {
  336. target.set('legacyGroupId', groupId); legacyData.GroupID = groupId;
  337. } else {
  338. const disabled = action === 'lock'; target.set('isDisabled', disabled); legacyData.State = disabled ? 0 : 1;
  339. if (userFields.statusAction) target.set('statusAction', disabled ? 'admin-lock' : 'admin-unlock');
  340. if (userFields.statusReason) target.set('statusReason', String(input.reason || '').trim().slice(0, 200));
  341. if (userFields.statusUpdatedAt) target.set('statusUpdatedAt', now);
  342. if (userFields.statusUpdatedBy) target.set('statusUpdatedBy', context.current.id);
  343. }
  344. target.set('legacyUserData', legacyData);
  345. }
  346. await Parse.Object.saveAll(targets, { useMasterKey: true });
  347. const revokedSessions = action === 'lock' ? await revokeSessions(targets) : 0;
  348. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'user-' + action, '_User', target.id);
  349. return response.json({ success: true, data: { action, updated: targets.length, revokedSessions, group: group ? serializeObject(group) : null, results: targets.map(serializeObject) } });
  350. }
  351. if (operation === 'saveGroup') {
  352. const objectId = String(input.objectId || '');
  353. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  354. const groupName = String(payload.groupName || '').trim();
  355. const description = String(payload.description || groupName).trim();
  356. const parentGroupId = payload.parentGroupId === undefined || payload.parentGroupId === '' ? 0 : Number(payload.parentGroupId);
  357. if (!groupName || groupName.length > 100) fail(400, '用户组名称为必填项且不能超过 100 位');
  358. if (description.length > 500) fail(400, '用户组说明不能超过 500 位');
  359. if (!Number.isInteger(parentGroupId) || parentGroupId < 0) fail(400, '父用户组编号无效');
  360. let companyId = pointerId(context.company);
  361. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  362. if (!companyId) fail(400, '用户组必须指定帐套');
  363. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  364. const groupFields = await schemaFor('Group');
  365. let target;
  366. let currentGroupId = 0;
  367. if (objectId) {
  368. const query = new Parse.Query('Group'); applyTenant(query, groupFields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  369. currentGroupId = Number(target.get('groupId')) || 0;
  370. } else target = new Parse.Object('Group');
  371. 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]);
  372. if (duplicate) fail(409, '当前帐套已存在同名用户组');
  373. if (parentGroupId) {
  374. let cursor = parentGroupId; const visited = new Set();
  375. while (cursor) {
  376. if (cursor === currentGroupId || visited.has(cursor)) fail(400, '父用户组不能形成循环');
  377. visited.add(cursor);
  378. const parent = await Psql.oneOrNone('SELECT "parentGroupId" AS parent_id FROM "Group" WHERE "company"=$1 AND "groupId"=$2 LIMIT 1', [companyId, cursor]);
  379. if (!parent) fail(404, '父用户组不存在或不属于当前帐套');
  380. cursor = Number(parent.parent_id) || 0;
  381. if (visited.size > 100) fail(400, '用户组层级过深');
  382. }
  383. }
  384. target.set('company', company); target.set('groupName', groupName); target.set('description', description); target.set('parentGroupId', parentGroupId);
  385. const stringFields = ['otherName','enroll','signImg'];
  386. const numberFields = ['orderId','companyGroup','vipgroup','rebateRate','credit','vipnum','upPoint','upSicon','favCount','consumeType','ccountPerDay','upGradeMoney'];
  387. for (const name of stringFields) if (payload[name] !== undefined && groupFields[name]) target.set(name, String(payload[name] || '').trim());
  388. 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); }
  389. if (payload.regSelect !== undefined && groupFields.regSelect) target.set('regSelect', payload.regSelect === true || payload.regSelect === 'true');
  390. if (!objectId && groupFields.sourceKey) target.set('sourceKey', 'cloud:admin-group:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10));
  391. await target.save(null, { useMasterKey: true });
  392. if (!objectId) {
  393. try {
  394. 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]);
  395. currentGroupId = Number(rows[0] && rows[0].id) || 0;
  396. if (!currentGroupId) throw new Error('无法分配用户组编号');
  397. } catch (error) { await target.destroy({ useMasterKey: true }).catch(() => undefined); throw error; }
  398. }
  399. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-group' : 'create-group', 'Group', target.id);
  400. return response.json({ success: true, data: serializeObject(target) });
  401. }
  402. if (operation === 'saveNode') {
  403. const objectId = String(input.objectId || '');
  404. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  405. const nodeName = String(payload.nodeName || '').trim();
  406. const nodeDir = String(payload.nodeDir || '').trim();
  407. const parentId = Number(input.parentId == null ? payload.parentId || 0 : input.parentId);
  408. if (!nodeName || nodeName.length > 100) fail(400, '栏目名称长度应为 1 至 100 位');
  409. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父节点编号无效');
  410. let companyId = pointerId(context.company);
  411. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  412. if (!companyId) fail(400, '栏目必须指定帐套');
  413. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  414. const fields = await schemaFor('Node');
  415. let target;
  416. let currentNodeId = 0;
  417. if (objectId) {
  418. const query = new Parse.Query('Node'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  419. companyId = pointerId(target.get('company')) || companyId;
  420. currentNodeId = Number(target.get('nodeId')) || 0;
  421. } else target = new Parse.Object('Node');
  422. let parentDepth = 0;
  423. if (parentId > 0) {
  424. 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]);
  425. if (!parent) fail(404, '父节点不存在、已回收或不属于当前帐套');
  426. parentDepth = Number(parent.depth) || 0;
  427. if (currentNodeId) {
  428. 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]);
  429. if (cycle) fail(409, '不能把栏目移动到自身或其下级栏目');
  430. }
  431. }
  432. 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]);
  433. if (duplicate) fail(409, '同一父栏目下的栏目名称或目录名不能重复');
  434. for (const [name, value] of Object.entries(payload)) {
  435. if (!fields[name] || isSystemField('Node', name)) continue;
  436. if (!GENERIC_WRITE_TYPES.has(fields[name].type)) fail(400, '字段不允许编辑: ' + name);
  437. if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
  438. }
  439. target.set('nodeName', nodeName); target.set('nodeDir', nodeDir); target.set('parentId', parentId); target.set('depth', parentDepth + 1); target.set('company', company);
  440. if (!objectId) {
  441. target.set('sourceKey', 'cloud:admin-node:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10));
  442. target.set('zstatus', 99); target.set('child', 0); target.set('cdate', new Date());
  443. if (fields.cuser) target.set('cuser', Number(context.current.get('legacyUserId')) || 0);
  444. if (fields.cuname) target.set('cuname', String(context.current.get('username') || ''));
  445. }
  446. if (fields.editDate) target.set('editDate', new Date());
  447. try { await target.save(null, { useMasterKey: true }); }
  448. catch (error) { fail(422, '栏目初始写入失败: ' + String(error && error.message || error)); }
  449. try {
  450. if (!objectId) {
  451. 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]);
  452. currentNodeId = Number(rows[0] && rows[0].id) || 0;
  453. if (!currentNodeId) throw new Error('无法分配栏目编号');
  454. }
  455. 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]);
  456. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '栏目结构写入失败: ' + String(error && error.message || error)); }
  457. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-node' : 'create-node', 'Node', target.id);
  458. return response.json({ success: true, data: serializeObject(target) });
  459. }
  460. if (operation === 'nodeBatch') {
  461. const action = String(input.action || '');
  462. if (!['recycle','recover','move','purge'].includes(action)) fail(400, '不支持的栏目批量操作');
  463. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  464. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  465. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个栏目');
  466. const fields = await schemaFor('Node');
  467. const query = new Parse.Query('Node'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  468. const targets = await query.find({ useMasterKey: true });
  469. if (targets.length !== objectIds.length) fail(404, '部分栏目不存在或不属于当前帐套');
  470. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  471. if (companyIds.length !== 1) fail(400, '批量操作的栏目必须属于同一帐套');
  472. const companyId = companyIds[0];
  473. const nodeIds = targets.map((target) => Number(target.get('nodeId')) || 0);
  474. if (nodeIds.some((nodeId) => nodeId < 1)) fail(409, '栏目缺少有效旧系统编号');
  475. let parentId = null;
  476. let parentDepth = 0;
  477. if (action === 'move') {
  478. parentId = Number(input.parentId);
  479. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '目标父节点编号无效');
  480. if (parentId > 0) {
  481. 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]);
  482. if (!parent) fail(404, '目标父节点不存在、已回收或不属于当前帐套');
  483. parentDepth = Number(parent.depth) || 0;
  484. for (const nodeId of nodeIds) {
  485. 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]);
  486. if (cycle) fail(409, '不能把栏目移动到自身或其下级栏目');
  487. }
  488. }
  489. for (const target of targets) { target.set('parentId', parentId); target.set('depth', parentDepth + 1); if (fields.editDate) target.set('editDate', new Date()); }
  490. await Parse.Object.saveAll(targets, { useMasterKey: true });
  491. 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]);
  492. } else if (action === 'purge') {
  493. 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]);
  494. if (Number(refs.contents) || Number(refs.children) || Number(refs.auth) || Number(refs.templates)) fail(409, '栏目仍被内容、下级栏目、权限或模板引用,不能永久删除');
  495. await Parse.Object.destroyAll(targets, { useMasterKey: true });
  496. } else {
  497. const status = action === 'recycle' ? -2 : 99;
  498. for (const target of targets) { target.set('zstatus', status); if (fields.editDate) target.set('editDate', new Date()); }
  499. await Parse.Object.saveAll(targets, { useMasterKey: true });
  500. }
  501. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'node-' + action, 'Node', target.id);
  502. return response.json({ success: true, data: { action, updated: targets.length, parentId, results: action === 'purge' ? [] : targets.map(serializeObject) } });
  503. }
  504. if (operation === 'saveSpecial') {
  505. const objectId = String(input.objectId || '');
  506. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  507. const specName = String(payload.specName || '').trim();
  508. const specDir = String(payload.specDir || '').trim();
  509. const pid = Number(input.pid == null ? payload.pid || 0 : input.pid);
  510. if (!specName || specName.length > 100) fail(400, '专题名称长度应为 1 至 100 位');
  511. if (!specDir || specDir.length > 100) fail(400, '专题目录长度应为 1 至 100 位');
  512. if (!Number.isInteger(pid) || pid < 0) fail(400, '父专题编号无效');
  513. let companyId = pointerId(context.company);
  514. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  515. if (!companyId) fail(400, '专题必须指定帐套');
  516. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  517. const fields = await schemaFor('Special');
  518. let target;
  519. let currentSpecId = 0;
  520. if (objectId) {
  521. const query = new Parse.Query('Special'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  522. companyId = pointerId(target.get('company')) || companyId; currentSpecId = Number(target.get('specId')) || 0;
  523. } else target = new Parse.Object('Special');
  524. if (pid > 0) {
  525. const parent = await Psql.oneOrNone('SELECT "specId" FROM "Special" WHERE "company"=$1 AND "specId"=$2 LIMIT 1', [companyId, pid]);
  526. if (!parent) fail(404, '父专题不存在或不属于当前帐套');
  527. if (currentSpecId) {
  528. 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]);
  529. if (cycle) fail(409, '不能把专题移动到自身或其下级专题');
  530. }
  531. }
  532. 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]);
  533. if (duplicate) fail(409, '专题名称或目录不能重复');
  534. for (const [name, value] of Object.entries(payload)) {
  535. if (!fields[name] || isSystemField('Special', name)) continue;
  536. if (!GENERIC_WRITE_TYPES.has(fields[name].type)) fail(400, '字段不允许编辑: ' + name);
  537. if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
  538. }
  539. target.set('specName', specName); target.set('specDir', specDir); target.set('pid', pid); target.set('company', company); target.set('editDate', new Date());
  540. 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') || '')); }
  541. try { await target.save(null, { useMasterKey: true }); }
  542. catch (error) { fail(422, '专题初始写入失败: ' + String(error && error.message || error)); }
  543. try {
  544. if (!objectId) {
  545. 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]);
  546. currentSpecId = Number(rows[0] && rows[0].id) || 0;
  547. if (!currentSpecId) throw new Error('无法分配专题编号');
  548. }
  549. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '专题结构写入失败: ' + String(error && error.message || error)); }
  550. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-special' : 'create-special', 'Special', target.id);
  551. return response.json({ success: true, data: serializeObject(target) });
  552. }
  553. if (operation === 'specialBatch') {
  554. const action = String(input.action || '');
  555. if (action === 'merge') fail(501, 'migration_blocked: CommonModel.specialId 仅存在于 Parse Schema、PostgreSQL 物理列缺失,且商品主表未迁移,无法安全合并专题关系');
  556. if (action !== 'move') fail(400, '不支持的专题批量操作');
  557. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  558. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  559. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个专题');
  560. const fields = await schemaFor('Special');
  561. const query = new Parse.Query('Special'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  562. const targets = await query.find({ useMasterKey: true });
  563. if (targets.length !== objectIds.length) fail(404, '部分专题不存在或不属于当前帐套');
  564. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  565. if (companyIds.length !== 1) fail(400, '批量操作的专题必须属于同一帐套');
  566. const companyId = companyIds[0];
  567. const specIds = targets.map((target) => Number(target.get('specId')) || 0);
  568. const pid = Number(input.pid);
  569. if (!Number.isInteger(pid) || pid < 0) fail(400, '目标父专题编号无效');
  570. if (pid > 0) {
  571. const parent = await Psql.oneOrNone('SELECT "specId" FROM "Special" WHERE "company"=$1 AND "specId"=$2 LIMIT 1', [companyId, pid]);
  572. if (!parent) fail(404, '目标父专题不存在或不属于当前帐套');
  573. for (const specId of specIds) {
  574. 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]);
  575. if (cycle) fail(409, '不能把专题移动到自身或其下级专题');
  576. }
  577. }
  578. for (const target of targets) { target.set('pid', pid); target.set('editDate', new Date()); }
  579. await Parse.Object.saveAll(targets, { useMasterKey: true });
  580. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'special-move', 'Special', target.id);
  581. return response.json({ success: true, data: { action, updated: targets.length, pid, results: targets.map(serializeObject) } });
  582. }
  583. if (operation === 'saveModelMetadata' || operation === 'saveModelFieldMetadata') {
  584. const className = operation === 'saveModelMetadata' ? 'Model' : 'ModelField';
  585. const objectId = String(input.objectId || '');
  586. if (!objectId) fail(501, 'migration_blocked: 新增模型或字段需要同步创建 PostgreSQL 物理表/列及旧模板文件,托管云函数中未开放此 DDL 流程');
  587. const fields = await schemaFor(className);
  588. const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
  589. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  590. const allowed = className === 'Model'
  591. ? new Set(['modelName','itemName','itemUnit','itemIcon','description','islotsize','contentTemplate','thumbnail'])
  592. : new Set(['fieldAlias','fieldTips','description','isNotNull','isSearchForm','content','isShow','isView','showList','showWidth','isCopy','islotsize','isChain']);
  593. if (className === 'Model') {
  594. const modelName = String(payload.modelName == null ? target.get('modelName') || '' : payload.modelName).trim();
  595. if (!modelName || modelName.length > 100) fail(400, '模型名称长度应为 1 至 100 位');
  596. 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]);
  597. if (duplicate) fail(409, '模型名称已存在');
  598. target.set('modelName', modelName);
  599. } else {
  600. const fieldAlias = String(payload.fieldAlias == null ? target.get('fieldAlias') || '' : payload.fieldAlias).trim();
  601. if (!fieldAlias || fieldAlias.length > 100) fail(400, '字段别名长度应为 1 至 100 位');
  602. target.set('fieldAlias', fieldAlias);
  603. }
  604. for (const [name, value] of Object.entries(payload)) {
  605. if (!allowed.has(name) || !fields[name]) continue;
  606. if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
  607. }
  608. await target.save(null, { useMasterKey: true }); await audit(context, className === 'Model' ? 'update-model-metadata' : 'update-model-field-metadata', className, objectId);
  609. return response.json({ success: true, data: serializeObject(target) });
  610. }
  611. if (operation === 'modelFieldOrder') {
  612. const items = Array.isArray(input.items) ? input.items : [];
  613. if (!items.length || items.length > 100) fail(400, '每次请提交 1 至 100 个字段顺序');
  614. const objectIds = items.map((item) => String(item && item.objectId || '').trim());
  615. const orderIds = items.map((item) => Number(item && item.orderId));
  616. 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, '字段或顺序参数无效/重复');
  617. const fields = await schemaFor('ModelField');
  618. const query = new Parse.Query('ModelField'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  619. const targets = await query.find({ useMasterKey: true });
  620. if (targets.length !== objectIds.length) fail(404, '部分模型字段不存在或不属于当前帐套');
  621. const modelIds = [...new Set(targets.map((target) => Number(target.get('modelId')) || 0))];
  622. if (modelIds.length !== 1) fail(400, '只能对同一模型的字段排序');
  623. const targetMap = new Map(targets.map((target) => [target.id, target]));
  624. for (const item of items) targetMap.get(String(item.objectId)).set('orderId', Number(item.orderId));
  625. await Parse.Object.saveAll(targets, { useMasterKey: true });
  626. for (const target of targets) await audit(context, 'order-model-field', 'ModelField', target.id);
  627. return response.json({ success: true, data: { updated: targets.length, modelId: modelIds[0], results: targets.map(serializeObject) } });
  628. }
  629. if (operation === 'saveGuestbook') {
  630. const objectId = String(input.objectId || '');
  631. if (!objectId) fail(400, '普通留言新增必须走前台留言云函数;后台只能通过专用回复流程新增');
  632. const fields = await schemaFor('Guestbook');
  633. const query = new Parse.Query('Guestbook'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
  634. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  635. const title = String(payload.title == null ? target.get('title') || '' : payload.title).trim();
  636. const content = String(payload.tcontent == null ? target.get('tcontent') || '' : payload.tcontent);
  637. if (!title || title.length > 200) fail(400, '留言标题长度应为 1 至 200 位');
  638. if (content.length > 200000) fail(400, '留言内容过长');
  639. target.set('title', title); target.set('tcontent', content);
  640. await target.save(null, { useMasterKey: true }); await audit(context, 'update-guestbook', 'Guestbook', objectId);
  641. return response.json({ success: true, data: serializeObject(target) });
  642. }
  643. if (operation === 'guestbookReply') {
  644. const parentObjectId = String(input.parentObjectId || '');
  645. const title = String(input.title || '').trim() || '[管理员回复]';
  646. const content = String(input.content || '');
  647. if (!parentObjectId) fail(400, '缺少原留言');
  648. if (title.length > 200 || !content.trim() || content.length > 200000) fail(400, '回复标题或内容无效');
  649. const fields = await schemaFor('Guestbook');
  650. const query = new Parse.Query('Guestbook'); applyTenant(query, fields, context, input.companyId); const parent = await query.get(parentObjectId, { useMasterKey: true });
  651. 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;
  652. if (!companyId || !parentGid) fail(409, '原留言缺少帐套或旧系统编号');
  653. const reply = new Parse.Object('Guestbook');
  654. 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');
  655. try { await reply.save(null, { useMasterKey: true }); }
  656. catch (error) { fail(422, '管理员回复初始写入失败: ' + String(error && error.message || error)); }
  657. try {
  658. 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]);
  659. if (!Number(rows[0] && rows[0].id)) throw new Error('无法分配回复编号');
  660. } catch (error) { await reply.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '管理员回复结构写入失败: ' + String(error && error.message || error)); }
  661. await reply.fetch({ useMasterKey: true }); await audit({ ...context, company }, 'reply-guestbook', 'Guestbook', reply.id);
  662. return response.json({ success: true, data: serializeObject(reply) });
  663. }
  664. if (operation === 'guestbookBatch') {
  665. const action = String(input.action || '');
  666. if (!['audit','unaudit','recycle','recover','purge'].includes(action)) fail(400, '不支持的留言批量操作');
  667. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  668. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  669. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条留言');
  670. const fields = await schemaFor('Guestbook');
  671. const query = new Parse.Query('Guestbook'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  672. const targets = await query.find({ useMasterKey: true });
  673. if (targets.length !== objectIds.length) fail(404, '部分留言不存在或不属于当前帐套');
  674. if (action === 'purge') {
  675. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))]; const gids = targets.map((target) => Number(target.get('gid')) || 0);
  676. if (companyIds.length !== 1 || gids.some((gid) => gid < 1)) fail(409, '留言缺少有效帐套或编号');
  677. 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]);
  678. if (Number(children.count) > 0) fail(409, '留言仍有回复,不能永久删除');
  679. await Parse.Object.destroyAll(targets, { useMasterKey: true });
  680. } else {
  681. const status = action === 'recycle' ? -2 : action === 'unaudit' ? 0 : 99;
  682. for (const target of targets) target.set('status', status);
  683. await Parse.Object.saveAll(targets, { useMasterKey: true });
  684. }
  685. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'guestbook-' + action, 'Guestbook', target.id);
  686. return response.json({ success: true, data: { action, updated: targets.length, results: action === 'purge' ? [] : targets.map(serializeObject) } });
  687. }
  688. if (operation === 'saveGuestCategory') {
  689. const objectId = String(input.objectId || '');
  690. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  691. const catename = String(payload.catename || '').trim();
  692. const parentId = Number(input.parentId == null ? payload.parentId || 0 : input.parentId);
  693. let gtype = Number(input.gtype == null ? payload.gtype || 0 : input.gtype);
  694. if (!catename || catename.length > 100) fail(400, '分类名称长度应为 1 至 100 位');
  695. if (!Number.isInteger(parentId) || parentId < 0 || ![0,1].includes(gtype)) fail(400, '分类类型或父分类编号无效');
  696. let companyId = pointerId(context.company);
  697. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  698. if (!companyId) fail(400, '分类必须指定帐套');
  699. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  700. const fields = await schemaFor('Guestcate');
  701. let target;
  702. let currentCateId = 0;
  703. if (objectId) {
  704. const query = new Parse.Query('Guestcate'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  705. companyId = pointerId(target.get('company')) || companyId; currentCateId = Number(target.get('cateid')) || 0; gtype = Number(target.get('gtype')) || 0;
  706. } else target = new Parse.Object('Guestcate');
  707. if (gtype === 0 && parentId !== 0) fail(400, '留言分类只能建立为顶级分类');
  708. if (parentId > 0) {
  709. const parent = await Psql.oneOrNone('SELECT "cateid" FROM "Guestcate" WHERE "company"=$1 AND "cateid"=$2 AND COALESCE("gtype",0)=$3 LIMIT 1', [companyId, parentId, gtype]);
  710. if (!parent) fail(404, '父分类不存在、类型不一致或不属于当前帐套');
  711. if (currentCateId) {
  712. const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "cateid","parentId",ARRAY["cateid"::text] AS path FROM "Guestcate" WHERE "company"=$1 AND "cateid"=$2 UNION ALL SELECT c."cateid",c."parentId",chain.path||c."cateid"::text FROM "Guestcate" c JOIN chain ON c."cateid"=chain."parentId" WHERE c."company"=$1 AND NOT c."cateid"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "cateid"=$3 LIMIT 1', [companyId, parentId, currentCateId]);
  713. if (cycle) fail(409, '不能把分类移动到自身或其下级分类');
  714. }
  715. }
  716. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Guestcate" WHERE "company"=$1 AND "objectId"<>$2 AND COALESCE("gtype",0)=$3 AND COALESCE("parentId",0)=$4 AND LOWER(TRIM(COALESCE("catename",\'\')))=LOWER($5) LIMIT 1', [companyId, objectId, gtype, parentId, catename]);
  717. if (duplicate) fail(409, '同一父分类下的分类名称不能重复');
  718. const allowed = new Set(['catename','desc','status','needLog','isShowUnaudit','postAuth','zipImgSize','barImage','barOwner','permibit','sendScore','replyScore','barInfo','isPlat']);
  719. for (const [name, value] of Object.entries(payload)) {
  720. if (!allowed.has(name) || !fields[name]) continue;
  721. if (value === null) target.unset(name); else target.set(name, toParseValue(fields[name], value));
  722. }
  723. target.set('catename', catename); target.set('parentId', parentId); target.set('gtype', gtype); target.set('company', company);
  724. if (!objectId) { target.set('sourceKey', 'cloud:admin-guestcate:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); if (fields.status && payload.status == null) target.set('status', 1); }
  725. try { await target.save(null, { useMasterKey: true }); }
  726. catch (error) { fail(422, '分类初始写入失败: ' + String(error && error.message || error)); }
  727. try {
  728. if (!objectId) {
  729. const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-guestcate-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("cateid"),0)+1 AS id FROM "Guestcate",lock_row WHERE "company"=$1 AND COALESCE("cateid",0)>0), next_order AS MATERIALIZED (SELECT COALESCE(MAX("orderId"),0)+1 AS id FROM "Guestcate",lock_row WHERE "company"=$1 AND COALESCE("gtype",0)=$3 AND COALESCE("parentId",0)=$4 AND "objectId"<>$2) UPDATE "Guestcate" SET "cateid"=next_id.id,"orderId"=next_order.id,"sourceKey"=\'[["Cateid",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id, gtype, parentId]);
  730. currentCateId = Number(rows[0] && rows[0].id) || 0;
  731. if (!currentCateId) throw new Error('无法分配分类编号');
  732. }
  733. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '分类结构写入失败: ' + String(error && error.message || error)); }
  734. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-guest-category' : 'create-guest-category', 'Guestcate', target.id);
  735. return response.json({ success: true, data: serializeObject(target) });
  736. }
  737. if (operation === 'guestCategoryBatch') {
  738. const action = String(input.action || '');
  739. if (!['delete','recommend','unrecommend'].includes(action)) fail(400, '不支持的分类批量操作');
  740. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  741. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  742. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个分类');
  743. const fields = await schemaFor('Guestcate');
  744. const query = new Parse.Query('Guestcate'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  745. const targets = await query.find({ useMasterKey: true });
  746. if (targets.length !== objectIds.length) fail(404, '部分分类不存在或不属于当前帐套');
  747. if (action === 'delete') {
  748. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))]; const cateids = targets.map((target) => Number(target.get('cateid')) || 0);
  749. if (companyIds.length !== 1 || cateids.some((id) => id < 1)) fail(409, '分类缺少有效帐套或编号');
  750. const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "Guestcate" WHERE "company"=$1 AND "parentId"=ANY($2::numeric[]) AND NOT ("cateid"=ANY($2::numeric[]))) AS children,(SELECT COUNT(*)::int FROM "Guestbook" WHERE "company"=$1 AND "cateid"=ANY($2::numeric[])) AS messages,(SELECT COUNT(*)::int FROM "GuestBar" WHERE "company"=$1 AND "cateId"=ANY($2::numeric[])) AS posts', [companyIds[0], cateids]);
  751. if (Number(refs.children) || Number(refs.messages) || Number(refs.posts)) fail(409, '分类仍被下级分类、留言或帖子引用,不能删除');
  752. await Parse.Object.destroyAll(targets, { useMasterKey: true });
  753. } else {
  754. for (const target of targets) { if (Number(target.get('gtype')) !== 1) fail(400, '只有贴吧分类支持推荐'); target.set('barInfo', action === 'recommend' ? 'Recommend' : ''); }
  755. await Parse.Object.saveAll(targets, { useMasterKey: true });
  756. }
  757. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'guest-category-' + action, 'Guestcate', target.id);
  758. return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
  759. }
  760. if (operation === 'barAuthorization') {
  761. fail(501, 'migration_blocked: 目标 Schema 缺少旧 ZL_Guest_BarAuth 用户版块权限关系(Uid、BarID、Look、Send、Reply),无法安全读取或保存逐用户浏览、发帖和回复权限');
  762. }
  763. if (operation === 'barMedal') {
  764. fail(501, 'migration_blocked: 目标 Schema 缺少旧 ZL_Guest_Medals 勋章及用户帖子授予关系,无法安全授予或撤销帖子勋章');
  765. }
  766. if (operation === 'saveDictionaryCategory') {
  767. const objectId = String(input.objectId || '');
  768. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  769. const categoryName = String(payload.categoryname || '').trim();
  770. const isUsed = payload.isused == null ? true : payload.isused === true || payload.isused === 'true';
  771. if (!categoryName || categoryName.length > 50) fail(400, '字典分类名称长度应为 1 至 50 位');
  772. let companyId = pointerId(context.company);
  773. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  774. if (!companyId) fail(400, '字典分类必须指定帐套');
  775. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  776. const fields = await schemaFor('Datadiccategory');
  777. let target;
  778. if (objectId) {
  779. const query = new Parse.Query('Datadiccategory'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  780. companyId = pointerId(target.get('company')) || companyId;
  781. } else target = new Parse.Object('Datadiccategory');
  782. const duplicateQuery = new Parse.Query('Datadiccategory'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.limit(1000);
  783. const duplicates = await duplicateQuery.find({ useMasterKey: true });
  784. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('categoryname') || '').trim().toLowerCase() === categoryName.toLowerCase())) fail(409, '同一帐套的字典分类名称不能重复');
  785. target.set('categoryname', categoryName); target.set('isused', isUsed); target.set('company', company);
  786. if (objectId) {
  787. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '字典分类写入失败: ' + String(error && error.message || error)); }
  788. } else {
  789. let lastError;
  790. for (let attempt = 0; attempt < 5; attempt++) {
  791. const allCategories = new Parse.Query('Datadiccategory'); allCategories.limit(1000);
  792. const allItems = new Parse.Query('Datadic'); allItems.limit(1000);
  793. const [categoryRows, itemRows] = await Promise.all([allCategories.find({ useMasterKey: true }), allItems.find({ useMasterKey: true })]);
  794. const nextId = Math.max(0, ...categoryRows.map((entry) => Number(entry.get('diccateid')) || 0), ...itemRows.map((entry) => Number(entry.get('diccate')) || 0)) + 1;
  795. target.set('diccateid', nextId); target.set('sourceKey', '[["Diccateid",' + nextId + ']]');
  796. try { await target.save(null, { useMasterKey: true }); lastError = null; break; }
  797. catch (error) { lastError = error; if (!(Number(error && error.code) === 137 || /duplicate|unique/i.test(String(error && error.message || '')))) break; }
  798. }
  799. if (lastError || !target.id) fail(422, '字典分类原子编号写入失败: ' + String(lastError && lastError.message || lastError || '无法分配编号'));
  800. }
  801. await audit({ ...context, company }, objectId ? 'update-dictionary-category' : 'create-dictionary-category', 'Datadiccategory', target.id);
  802. return response.json({ success: true, data: serializeObject(target) });
  803. }
  804. if (operation === 'saveDictionaryItem') {
  805. const objectId = String(input.objectId || '');
  806. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  807. const itemName = String(payload.dicname || '').trim();
  808. const categoryId = Number(input.categoryId == null ? payload.diccate || 0 : input.categoryId);
  809. const isUsed = payload.isused == null ? true : payload.isused === true || payload.isused === 'true';
  810. if (!itemName || itemName.length > 50) fail(400, '字典项名称长度应为 1 至 50 位');
  811. if (!Number.isInteger(categoryId) || categoryId < 1) fail(400, '请选择有效字典分类');
  812. let companyId = pointerId(context.company);
  813. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  814. if (!companyId) fail(400, '字典项必须指定帐套');
  815. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  816. const fields = await schemaFor('Datadic');
  817. let target;
  818. if (objectId) {
  819. const query = new Parse.Query('Datadic'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  820. companyId = pointerId(target.get('company')) || companyId;
  821. } else target = new Parse.Object('Datadic');
  822. const categoryFields = await schemaFor('Datadiccategory'); const categoryQuery = new Parse.Query('Datadiccategory'); applyTenant(categoryQuery, categoryFields, context, input.companyId); categoryQuery.equalTo('diccateid', categoryId);
  823. const category = await categoryQuery.first({ useMasterKey: true });
  824. if (!category || pointerId(category.get('company')) !== companyId) fail(404, '字典分类不存在或不属于当前帐套');
  825. const duplicateQuery = new Parse.Query('Datadic'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.equalTo('diccate', categoryId); duplicateQuery.limit(1000);
  826. const duplicates = await duplicateQuery.find({ useMasterKey: true });
  827. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('dicname') || '').trim().toLowerCase() === itemName.toLowerCase())) fail(409, '同一字典分类中的字典项名称不能重复');
  828. target.set('dicname', itemName); target.set('diccate', categoryId); target.set('isused', isUsed); target.set('company', company);
  829. if (objectId) {
  830. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '字典项写入失败: ' + String(error && error.message || error)); }
  831. } else {
  832. let lastError;
  833. for (let attempt = 0; attempt < 5; attempt++) {
  834. const allItems = new Parse.Query('Datadic'); allItems.limit(1000); const itemRows = await allItems.find({ useMasterKey: true });
  835. const nextId = Math.max(0, ...itemRows.map((entry) => Number(entry.get('dicid')) || 0)) + 1;
  836. target.set('dicid', nextId); target.set('sourceKey', '[["Dicid",' + nextId + ']]');
  837. try { await target.save(null, { useMasterKey: true }); lastError = null; break; }
  838. catch (error) { lastError = error; if (!(Number(error && error.code) === 137 || /duplicate|unique/i.test(String(error && error.message || '')))) break; }
  839. }
  840. if (lastError || !target.id) fail(422, '字典项原子编号写入失败: ' + String(lastError && lastError.message || lastError || '无法分配编号'));
  841. }
  842. await audit({ ...context, company }, objectId ? 'update-dictionary-item' : 'create-dictionary-item', 'Datadic', target.id);
  843. return response.json({ success: true, data: serializeObject(target) });
  844. }
  845. if (operation === 'dictionaryCategories') {
  846. const fields = await schemaFor('Datadiccategory'); const query = new Parse.Query('Datadiccategory'); applyTenant(query, fields, context, input.companyId);
  847. if (input.enabledOnly === true) query.equalTo('isused', true);
  848. query.ascending('diccateid'); query.limit(1000);
  849. const results = await query.find({ useMasterKey: true });
  850. return response.json({ success: true, data: { results: results.map(serializeObject) } });
  851. }
  852. if (operation === 'dictionaryBatch') {
  853. const className = String(input.className || ''); const action = String(input.action || '');
  854. if (!['Datadiccategory','Datadic'].includes(className) || !['enable','disable','delete'].includes(action)) fail(400, '不支持的字典批量操作');
  855. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  856. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  857. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条字典记录');
  858. const fields = await schemaFor(className); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  859. const targets = await query.find({ useMasterKey: true });
  860. if (targets.length !== objectIds.length) fail(404, '部分字典记录不存在或不属于当前帐套');
  861. if (action === 'delete' && className === 'Datadiccategory') {
  862. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))]; const categoryIds = targets.map((target) => Number(target.get('diccateid')) || 0);
  863. if (companyIds.length !== 1 || categoryIds.some((id) => id < 1)) fail(409, '字典分类缺少有效帐套或编号');
  864. const itemFields = await schemaFor('Datadic'); const refs = new Parse.Query('Datadic'); applyTenant(refs, itemFields, context, companyIds[0]); refs.containedIn('diccate', categoryIds);
  865. if (await refs.count({ useMasterKey: true })) fail(409, '字典分类仍有字典项,不能删除');
  866. }
  867. if (action === 'delete') await Parse.Object.destroyAll(targets, { useMasterKey: true });
  868. else { for (const target of targets) target.set('isused', action === 'enable'); await Parse.Object.saveAll(targets, { useMasterKey: true }); }
  869. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'dictionary-' + action, className, target.id);
  870. return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
  871. }
  872. if (operation === 'saveGradeCategory') {
  873. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  874. const cateName = String(payload.cateName || '').trim(); const remark = String(payload.remark || '').trim();
  875. const aliases = String(payload.gradeField || '').split(/\r?\n|\|/).map((value) => value.trim()).filter(Boolean); const gradeField = aliases.join('|');
  876. if (!cateName || cateName.length > 50) fail(400, '多级字典分类名称长度应为 1 至 50 位');
  877. if (remark.length > 255) fail(400, '多级字典分类备注不能超过 255 位');
  878. if (aliases.length < 2 || gradeField.length > 500) fail(400, '层级别名至少填写 2 级,且总长度不能超过 500 位');
  879. let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '多级字典分类必须指定帐套');
  880. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('GradeCate'); let target;
  881. if (objectId) { const query = new Parse.Query('GradeCate'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; }
  882. else target = new Parse.Object('GradeCate');
  883. const duplicateQuery = new Parse.Query('GradeCate'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.limit(1000); const duplicates = await duplicateQuery.find({ useMasterKey: true });
  884. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('cateName') || '').trim().toLowerCase() === cateName.toLowerCase())) fail(409, '同一帐套的多级字典分类名称不能重复');
  885. target.set('cateName', cateName); target.set('remark', remark); target.set('gradeField', gradeField); target.set('company', company);
  886. if (objectId) { try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '多级字典分类写入失败: ' + String(error && error.message || error)); } }
  887. else {
  888. let lastError;
  889. for (let attempt = 0; attempt < 5; attempt++) {
  890. const categories = new Parse.Query('GradeCate'); categories.limit(1000); const options = new Parse.Query('Grade'); options.limit(1000);
  891. const [categoryRows, optionRows] = await Promise.all([categories.find({ useMasterKey: true }), options.find({ useMasterKey: true })]);
  892. const nextId = Math.max(0, ...categoryRows.map((entry) => Number(entry.get('cateId')) || 0), ...optionRows.map((entry) => Number(entry.get('cate')) || 0)) + 1;
  893. target.set('cateId', nextId); target.set('sourceKey', '[["CateID",' + nextId + ']]');
  894. try { await target.save(null, { useMasterKey: true }); lastError = null; break; }
  895. catch (error) { lastError = error; if (!(Number(error && error.code) === 137 || /duplicate|unique/i.test(String(error && error.message || '')))) break; }
  896. }
  897. if (lastError || !target.id) fail(422, '多级字典分类原子编号写入失败: ' + String(lastError && lastError.message || lastError || '无法分配编号'));
  898. }
  899. await audit({ ...context, company }, objectId ? 'update-grade-category' : 'create-grade-category', 'GradeCate', target.id);
  900. return response.json({ success: true, data: serializeObject(target) });
  901. }
  902. if (operation === 'saveGradeOption') {
  903. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const gradeName = String(payload.gradeName || '').trim();
  904. if (!gradeName || gradeName.length > 50) fail(400, '多级字典选项名称长度应为 1 至 50 位');
  905. let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '多级字典选项必须指定帐套');
  906. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('Grade'); let target; let categoryId; let parentId; let level;
  907. if (objectId) {
  908. const query = new Parse.Query('Grade'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId;
  909. categoryId = Number(target.get('cate')) || 0; parentId = Number(target.get('parentId')) || 0; level = Number(target.get('grade')) || 0;
  910. } else { target = new Parse.Object('Grade'); categoryId = Number(input.categoryId == null ? payload.cate || 0 : input.categoryId); parentId = Number(input.parentId == null ? payload.parentId || 0 : input.parentId); level = 1; }
  911. if (!Number.isInteger(categoryId) || categoryId < 1 || !Number.isInteger(parentId) || parentId < 0) fail(400, '多级字典分类或父选项编号无效');
  912. const categoryFields = await schemaFor('GradeCate'); const categoryQuery = new Parse.Query('GradeCate'); applyTenant(categoryQuery, categoryFields, context, input.companyId); categoryQuery.equalTo('cateId', categoryId); const category = await categoryQuery.first({ useMasterKey: true });
  913. if (!category || pointerId(category.get('company')) !== companyId) fail(404, '多级字典分类不存在或不属于当前帐套');
  914. const aliases = String(category.get('gradeField') || '').split('|').map((value) => value.trim()).filter(Boolean);
  915. if (!objectId && parentId > 0) { const parentQuery = new Parse.Query('Grade'); applyTenant(parentQuery, fields, context, input.companyId); parentQuery.equalTo('gradeId', parentId); const parent = await parentQuery.first({ useMasterKey: true }); if (!parent || Number(parent.get('cate')) !== categoryId || pointerId(parent.get('company')) !== companyId) fail(404, '父选项不存在、分类不一致或不属于当前帐套'); level = Number(parent.get('grade')) + 1; }
  916. if (!Number.isInteger(level) || level < 1 || level > aliases.length) fail(409, '当前层级已超过分类定义的层级别名');
  917. const duplicateQuery = new Parse.Query('Grade'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.equalTo('cate', categoryId); duplicateQuery.equalTo('parentId', parentId); duplicateQuery.limit(1000); const duplicates = await duplicateQuery.find({ useMasterKey: true });
  918. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('gradeName') || '').trim().toLowerCase() === gradeName.toLowerCase())) fail(409, '同一父级下的多级字典选项名称不能重复');
  919. target.set('gradeName', gradeName); target.set('cate', categoryId); target.set('parentId', parentId); target.set('grade', level); target.set('company', company);
  920. if (objectId) { try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '多级字典选项写入失败: ' + String(error && error.message || error)); } }
  921. else {
  922. let lastError;
  923. for (let attempt = 0; attempt < 5; attempt++) {
  924. const allOptions = new Parse.Query('Grade'); allOptions.limit(1000); const optionRows = await allOptions.find({ useMasterKey: true }); const nextId = Math.max(0, ...optionRows.map((entry) => Number(entry.get('gradeId')) || 0)) + 1;
  925. target.set('gradeId', nextId); target.set('sourceKey', '[["GradeID",' + nextId + ']]');
  926. try { await target.save(null, { useMasterKey: true }); lastError = null; break; }
  927. catch (error) { lastError = error; if (!(Number(error && error.code) === 137 || /duplicate|unique/i.test(String(error && error.message || '')))) break; }
  928. }
  929. if (lastError || !target.id) fail(422, '多级字典选项原子编号写入失败: ' + String(lastError && lastError.message || lastError || '无法分配编号'));
  930. }
  931. await audit({ ...context, company }, objectId ? 'update-grade-option' : 'create-grade-option', 'Grade', target.id);
  932. return response.json({ success: true, data: serializeObject(target) });
  933. }
  934. if (operation === 'gradeCategories') {
  935. const fields = await schemaFor('GradeCate'); const query = new Parse.Query('GradeCate'); applyTenant(query, fields, context, input.companyId); query.ascending('cateId'); query.limit(1000); const results = await query.find({ useMasterKey: true });
  936. return response.json({ success: true, data: { results: results.map(serializeObject) } });
  937. }
  938. if (operation === 'gradeOptions') {
  939. const categoryId = Number(input.categoryId || 0); const parentId = input.parentId == null || input.parentId === '' ? -1 : Number(input.parentId); if (!Number.isInteger(categoryId) || categoryId < 1 || !Number.isInteger(parentId) || parentId < -1) fail(400, '多级字典筛选条件无效');
  940. const fields = await schemaFor('Grade'); const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const query = new Parse.Query('Grade'); applyTenant(query, fields, context, input.companyId); query.equalTo('cate', categoryId); if (parentId >= 0) query.equalTo('parentId', parentId);
  941. const search = String(input.search || '').trim(); if (search) query.matches('gradeName', escapeRegex(search), 'i'); const total = await query.count({ useMasterKey: true }); query.ascending('gradeId'); query.skip((page - 1) * pageSize); query.limit(pageSize); const results = await query.find({ useMasterKey: true });
  942. return response.json({ success: true, data: { className: 'Grade', page, pageSize, total, results: results.map(serializeObject) } });
  943. }
  944. if (operation === 'gradeBatch') {
  945. const className = String(input.className || ''); if (!['GradeCate','Grade'].includes(className) || String(input.action || '') !== 'delete') fail(400, '不支持的多级字典批量操作');
  946. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条多级字典记录');
  947. const fields = await schemaFor(className); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分多级字典记录不存在或不属于当前帐套');
  948. if (className === 'GradeCate') { const ids = targets.map((target) => Number(target.get('cateId')) || 0); const optionFields = await schemaFor('Grade'); const refs = new Parse.Query('Grade'); applyTenant(refs, optionFields, context, input.companyId); refs.containedIn('cate', ids); if (await refs.count({ useMasterKey: true })) fail(409, '多级字典分类仍有选项,不能删除'); }
  949. else { const ids = targets.map((target) => Number(target.get('gradeId')) || 0); const refs = new Parse.Query('Grade'); applyTenant(refs, fields, context, input.companyId); refs.containedIn('parentId', ids); if (await refs.count({ useMasterKey: true })) fail(409, '多级字典选项仍有下级选项,不能删除'); }
  950. await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'grade-delete', className, target.id);
  951. return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
  952. }
  953. if (operation === 'saveCurrency') {
  954. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  955. const title = String(payload.title || '').trim(); const currencyName = String(payload.currencyName || '').trim().toUpperCase(); const currencySymbol = String(payload.currencySymbol || '').trim(); const exchange = Number(payload.currentExchange); const remark = String(payload.remark || '').trim();
  956. if (!title || title.length > 50) fail(400, '货币名称长度应为 1 至 50 位');
  957. if (!/^[A-Z][A-Z0-9]{1,9}$/.test(currencyName)) fail(400, '货币代码应为 2 至 10 位大写字母或数字');
  958. if (!currencySymbol || currencySymbol.length > 16) fail(400, '货币符号长度应为 1 至 16 位');
  959. if (!Number.isFinite(exchange) || exchange <= 0 || exchange > 1000000000) fail(400, '当前汇率必须是大于 0 的有效数字');
  960. if (remark.length > 255) fail(400, '货币备注不能超过 255 位');
  961. let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '货币必须指定帐套');
  962. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('Currency'); let target;
  963. if (!objectId) fail(501, 'migration_blocked: Currency.id 与 Parse 对象保留 id 冲突,且 PostgreSQL 物理表缺少可写旧 ID 列,不能安全新增货币');
  964. { const query = new Parse.Query('Currency'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; }
  965. const duplicateQuery = new Parse.Query('Currency'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.limit(1000); const duplicates = await duplicateQuery.find({ useMasterKey: true });
  966. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('title') || '').trim().toLowerCase() === title.toLowerCase())) fail(409, '同一帐套的货币名称不能重复');
  967. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('currencyName') || '').trim().toUpperCase() === currencyName)) fail(409, '同一帐套的货币代码不能重复');
  968. target.set('title', title); target.set('currencyName', currencyName); target.set('currencySymbol', currencySymbol); target.set('currentExchange', exchange); target.set('remark', remark); target.set('company', company);
  969. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '货币写入失败: ' + String(error && error.message || error)); }
  970. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-currency' : 'create-currency', 'Currency', target.id); return response.json({ success: true, data: serializeObject(target) });
  971. }
  972. if (operation === 'currencyBatch') {
  973. if (String(input.className || '') !== 'Currency' || String(input.action || '') !== 'delete') fail(400, '不支持的货币批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条货币记录');
  974. const fields = await schemaFor('Currency'); const query = new Parse.Query('Currency'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分货币记录不存在或不属于当前帐套'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'currency-delete', 'Currency', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
  975. }
  976. if (operation === 'saveHoliday') {
  977. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  978. if (!objectId) fail(501, 'migration_blocked: SysHoliday.id 与 Parse 对象保留 id 冲突,且 PostgreSQL 物理表缺少可写旧 ID 列,不能安全新增节假日');
  979. const name = String(payload.name || '').trim(); const htype = String(payload.htype || '').trim(); const hdate = String(payload.hdate || '').trim();
  980. const hdateStart = Number(payload.hdateStart); const hdateEnd = Number(payload.hdateEnd); const zstatus = Number(payload.zstatus);
  981. const strings = { ztype: String(payload.ztype || '').trim(), himage: String(payload.himage || '').trim(), remind: String(payload.remind || '').trim(), heffect: String(payload.heffect || '').trim(), hcontent: String(payload.hcontent || '').trim() };
  982. if (!name || name.length > 100) fail(400, '节假日名称长度应为 1 至 100 位');
  983. if (!['公历','农历'].includes(htype)) fail(400, '节假日类型只能是公历或农历');
  984. if (!/^(0[1-9]|1[0-2])月(0[1-9]|[12][0-9]|3[01])日$/.test(hdate)) fail(400, '节假日日期必须使用 MM月DD日 格式');
  985. if (!Number.isInteger(hdateStart) || hdateStart < 0 || hdateStart > 365 || !Number.isInteger(hdateEnd) || hdateEnd < 0 || hdateEnd > 365) fail(400, '提前与延后影响天数必须是 0 至 365 的整数');
  986. if (![0,99].includes(zstatus)) fail(400, '节假日状态只能是停用或启用');
  987. if (strings.ztype.length > 100 || strings.himage.length > 500 || strings.remind.length > 1000 || strings.heffect.length > 500 || strings.hcontent.length > 4000) fail(400, '节假日文本字段超过允许长度');
  988. const fields = await schemaFor('SysHoliday'); const query = new Parse.Query('SysHoliday'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
  989. const duplicateQuery = new Parse.Query('SysHoliday'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.limit(1000); const duplicates = await duplicateQuery.find({ useMasterKey: true });
  990. if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('name') || '').trim().toLowerCase() === name.toLowerCase() && String(entry.get('htype') || '') === htype && String(entry.get('hdate') || '') === hdate)) fail(409, '同一帐套的节假日名称、类型和日期不能完全重复');
  991. target.set('name', name); target.set('htype', htype); target.set('hdate', hdate); target.set('hdateStart', hdateStart); target.set('hdateEnd', hdateEnd); target.set('zstatus', zstatus);
  992. for (const [field, value] of Object.entries(strings)) target.set(field, value);
  993. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '节假日写入失败: ' + String(error && error.message || error)); }
  994. await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-holiday', 'SysHoliday', target.id); return response.json({ success: true, data: serializeObject(target) });
  995. }
  996. if (operation === 'holidayBatch') {
  997. if (String(input.className || '') !== 'SysHoliday' || String(input.action || '') !== 'delete') fail(400, '不支持的节假日批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条节假日记录');
  998. const fields = await schemaFor('SysHoliday'); const query = new Parse.Query('SysHoliday'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分节假日记录不存在或不属于当前帐套'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'holiday-delete', 'SysHoliday', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
  999. }
  1000. if (operation === 'searchNavigations') {
  1001. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const type = Number(input.type == null ? 1 : input.type); const state = Number(input.state == null ? -100 : input.state); const elite = Number(input.elite == null ? -100 : input.elite);
  1002. if (![1,2].includes(type) || ![-100,1,2].includes(state) || ![-100,0,1].includes(elite)) fail(400, '快捷入口筛选条件无效');
  1003. const fields = await schemaFor('Search'); const query = new Parse.Query('Search'); applyTenant(query, fields, context, input.companyId); query.limit(1000); const search = String(input.search || '').trim().toLowerCase();
  1004. let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => Number(entry.get('type')) === type && (state === -100 || Number(entry.get('state')) === state) && (elite === -100 || (elite === 1 ? Number(entry.get('eliteLevel')) > 0 : Number(entry.get('eliteLevel')) === 0)) && (!search || String(entry.get('name') || '').toLowerCase().includes(search) || String(entry.get('fileUrl') || '').toLowerCase().includes(search)));
  1005. results.sort((left, right) => Number(left.get('orderId') || 0) - Number(right.get('orderId') || 0) || Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
  1006. return response.json({ success: true, data: { className: 'Search', page, pageSize, total, results: results.map(serializeObject) } });
  1007. }
  1008. if (operation === 'saveSearchNavigation') {
  1009. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1010. if (!objectId) fail(501, 'migration_blocked: Search.id 与 Parse 对象保留 id 冲突,且 PostgreSQL 物理表缺少可写旧 ID 列,不能安全新增快捷入口');
  1011. const name = String(payload.name || '').trim(); const fileUrl = String(payload.fileUrl || '').trim(); const ico = String(payload.ico || '').trim(); const bkcolor = String(payload.bkcolor || '').trim(); const userGroup = String(payload.userGroup || '').trim();
  1012. const openType = Number(payload.openType); const mobile = Number(payload.mobile); const size = Number(payload.size); const eliteLevel = Number(payload.eliteLevel);
  1013. if (!name || name.length > 100) fail(400, '快捷入口名称长度应为 1 至 100 位');
  1014. if (!fileUrl || fileUrl.length > 500 || !fileUrl.startsWith('/') || fileUrl.startsWith('//') || /^[a-z]+:/i.test(fileUrl)) fail(400, '快捷入口地址必须是 1 至 500 位的站内路径');
  1015. if (ico.length > 100 || (bkcolor && !/^#[0-9a-f]{6}$/i.test(bkcolor))) fail(400, '图标或背景颜色格式无效');
  1016. if (![0,1].includes(openType) || ![0,1].includes(mobile) || !Number.isInteger(size) || size < 1 || size > 4 || !Number.isInteger(eliteLevel) || eliteLevel < 0 || eliteLevel > 10) fail(400, '快捷入口展示参数无效');
  1017. if (userGroup && !/^\d+(,\d+)*$/.test(userGroup)) fail(400, '用户组必须使用逗号分隔的正整数 ID');
  1018. const fields = await schemaFor('Search'); const query = new Parse.Query('Search'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
  1019. target.set('name', name); target.set('fileUrl', fileUrl); target.set('ico', ico); target.set('openType', openType); target.set('mobile', mobile); target.set('size', size); target.set('bkcolor', bkcolor); target.set('userGroup', userGroup); target.set('eliteLevel', eliteLevel);
  1020. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '快捷入口写入失败: ' + String(error && error.message || error)); }
  1021. await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-search-navigation', 'Search', target.id); return response.json({ success: true, data: serializeObject(target) });
  1022. }
  1023. if (operation === 'searchNavigationBatch') {
  1024. if (String(input.className || '') !== 'Search') fail(400, '只能处理快捷入口'); const action = String(input.action || '');
  1025. if (action === 'starturl') fail(501, 'migration_blocked: 目标云函数架构没有旧 SiteConfig.SiteOption.Admin_StartUrl 持久层,不能伪造全局后台起始页');
  1026. if (!['enable','disable','elite','unelite','delete','order'].includes(action)) fail(400, '不支持的快捷入口批量操作');
  1027. let requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; let orders = [];
  1028. if (action === 'order') { orders = Array.isArray(input.orders) ? input.orders.map((entry) => ({ objectId: String(entry && entry.objectId || '').trim(), orderId: Number(entry && entry.orderId) })) : []; if (!orders.length || orders.length > 100 || orders.some((entry) => !entry.objectId || !Number.isInteger(entry.orderId) || entry.orderId < 1 || entry.orderId > 1000000)) fail(400, '排序必须包含 1 至 100 条有效入口及正整数顺序'); requestedIds = orders.map((entry) => entry.objectId); }
  1029. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100 || (action === 'order' && objectIds.length !== orders.length)) fail(400, '每次请选择 1 至 100 条唯一快捷入口');
  1030. const fields = await schemaFor('Search'); const query = new Parse.Query('Search'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分快捷入口不存在或不属于当前帐套');
  1031. if (action === 'delete') await Parse.Object.destroyAll(targets, { useMasterKey: true });
  1032. else { const orderMap = new Map(orders.map((entry) => [entry.objectId, entry.orderId])); for (const target of targets) { if (action === 'enable') target.set('state', 1); else if (action === 'disable') target.set('state', 2); else if (action === 'elite') target.set('eliteLevel', 1); else if (action === 'unelite') target.set('eliteLevel', 0); else target.set('orderId', orderMap.get(target.id)); } await Parse.Object.saveAll(targets, { useMasterKey: true }); }
  1033. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'search-navigation-' + action, 'Search', target.id); return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
  1034. }
  1035. if (operation === 'adZones') {
  1036. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(1000, Math.max(1, Number(input.pageSize) || 20)); const status = Number(input.status == null ? -100 : input.status); const type = String(input.type || '').trim(); const search = String(input.search || '').trim().toLowerCase();
  1037. if (![-100,0,99].includes(status) || type.length > 50 || search.length > 100) fail(400, '广告位筛选条件无效');
  1038. const fields = await schemaFor('AdZone'); const query = new Parse.Query('AdZone'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true });
  1039. results = results.filter((entry) => (status === -100 || Number(entry.get('zstatus')) === status) && (!type || String(entry.get('ztype') || '') === type) && (!search || [entry.get('name'),entry.get('remind'),entry.get('ztype')].some((value) => String(value || '').toLowerCase().includes(search))));
  1040. results.sort((left, right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
  1041. return response.json({ success: true, data: { className: 'AdZone', page, pageSize, total, results: results.map(serializeObject) } });
  1042. }
  1043. if (operation === 'adInfos') {
  1044. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const status = Number(input.status == null ? -100 : input.status); const zoneId = String(input.zoneId || '').trim(); const search = String(input.search || '').trim().toLowerCase();
  1045. if (![-100,0,99].includes(status) || (zoneId && !/^\d+$/.test(zoneId)) || search.length > 100) fail(400, '广告内容筛选条件无效');
  1046. const fields = await schemaFor('AdInfo'); const query = new Parse.Query('AdInfo'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true });
  1047. results = results.filter((entry) => (status === -100 || Number(entry.get('zstatus')) === status) && (!zoneId || String(entry.get('zoneId') || '') === zoneId) && (!search || [entry.get('name'),entry.get('linkUrl'),entry.get('text'),entry.get('remind')].some((value) => String(value || '').toLowerCase().includes(search))));
  1048. results.sort((left, right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
  1049. return response.json({ success: true, data: { className: 'AdInfo', page, pageSize, total, results: results.map(serializeObject) } });
  1050. }
  1051. if (operation === 'saveAdZone') {
  1052. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1053. if (!objectId) fail(501, 'migration_blocked: AdZone.id 与 Parse 对象保留 id 冲突,且省略 id 创建不会生成旧广告位编号,不能安全新增广告位');
  1054. const name = String(payload.name || '').trim(); const remind = String(payload.remind || '').trim(); const ztype = String(payload.ztype || '').trim(); const showType = String(payload.showType || '').trim(); const zstatus = Number(payload.zstatus);
  1055. if (!name || name.length > 100 || remind.length > 2000 || !ztype || ztype.length > 50 || !showType || showType.length > 50 || ![0,99].includes(zstatus)) fail(400, '广告位名称、类型、展示方式、状态或备注无效');
  1056. const fields = await schemaFor('AdZone'); const query = new Parse.Query('AdZone'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
  1057. target.set('name', name); target.set('remind', remind); target.set('ztype', ztype); target.set('showType', showType); target.set('zstatus', zstatus); const adminId = Number(context.current.get('legacyUserId')) || 0; if (adminId) target.set('cadmin', adminId);
  1058. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '广告位写入失败: ' + String(error && error.message || error)); }
  1059. await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-ad-zone', 'AdZone', target.id); return response.json({ success: true, data: serializeObject(target) });
  1060. }
  1061. if (operation === 'saveAdInfo') {
  1062. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1063. if (!objectId) fail(501, 'migration_blocked: AdInfo.id 与 Parse 对象保留 id 冲突,且省略 id 创建不会生成旧广告编号,不能安全新增广告内容');
  1064. const name = String(payload.name || '').trim(); const zoneId = String(input.zoneId == null ? payload.zoneId || '' : input.zoneId).trim(); const hasPriority = Object.prototype.hasOwnProperty.call(payload, 'priority'); const priority = hasPriority ? Number(payload.priority) : 0; const zstatus = Number(payload.zstatus);
  1065. const valueOfDate = (value) => value && typeof value === 'object' && value.iso ? value.iso : value; const sdate = new Date(valueOfDate(payload.sdate)); const edate = new Date(valueOfDate(payload.edate));
  1066. const strings = { linkUrl: String(payload.linkUrl || '').trim(), text: String(payload.text || ''), html: String(payload.html || ''), images: String(payload.images || '').trim(), image: String(payload.image || '').trim(), remind: String(payload.remind || '').trim(), style: String(payload.style || '') };
  1067. if (!name || name.length > 100 || !/^\d+$/.test(zoneId) || (hasPriority && (String(payload.priority).trim() === '' || !Number.isInteger(priority) || priority < 0 || priority > 1000000)) || ![0,99].includes(zstatus)) fail(400, '广告名称、广告位、权重或状态无效');
  1068. if (Number.isNaN(sdate.getTime()) || Number.isNaN(edate.getTime()) || edate < sdate) fail(400, '广告到期时间必须大于或等于开始时间');
  1069. if (strings.linkUrl.length > 2000 || strings.text.length > 20000 || strings.html.length > 200000 || strings.images.length > 10000 || strings.image.length > 2000 || strings.remind.length > 2000 || strings.style.length > 10000) fail(400, '广告内容字段超过允许长度');
  1070. const zoneFields = await schemaFor('AdZone'); const zoneQuery = new Parse.Query('AdZone'); applyTenant(zoneQuery, zoneFields, context, input.companyId); zoneQuery.limit(1000); const zones = await zoneQuery.find({ useMasterKey: true }); if (!zones.some((entry) => String(entry.get('id') || '') === zoneId)) fail(404, '目标广告位不存在或不属于当前帐套');
  1071. const fields = await schemaFor('AdInfo'); const query = new Parse.Query('AdInfo'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
  1072. target.set('name', name); target.set('zoneId', zoneId); if (hasPriority) target.set('priority', String(priority)); target.set('zstatus', zstatus); target.set('sdate', sdate); target.set('edate', edate); for (const [field, value] of Object.entries(strings)) if (Object.prototype.hasOwnProperty.call(payload, field)) target.set(field, value);
  1073. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '广告内容写入失败: ' + String(error && error.message || error)); }
  1074. await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-ad-info', 'AdInfo', target.id); return response.json({ success: true, data: serializeObject(target) });
  1075. }
  1076. if (operation === 'adZoneBatch') {
  1077. if (String(input.className || '') !== 'AdZone') fail(400, '只能处理广告位'); const action = String(input.action || ''); if (!['active','pause','delete'].includes(action)) fail(400, '不支持的广告位批量操作');
  1078. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个广告位');
  1079. const fields = await schemaFor('AdZone'); const query = new Parse.Query('AdZone'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分广告位不存在或不属于当前帐套');
  1080. if (action === 'delete') { const zoneIds = new Set(targets.map((target) => String(target.get('id') || ''))); const infoFields = await schemaFor('AdInfo'); const refsQuery = new Parse.Query('AdInfo'); applyTenant(refsQuery, infoFields, context, input.companyId); refsQuery.limit(1000); const refs = await refsQuery.find({ useMasterKey: true }); if (refs.length >= 1000) fail(409, '广告内容数量超过安全核验上限,不能删除广告位'); if (refs.some((entry) => zoneIds.has(String(entry.get('zoneId') || '')))) fail(409, '广告位仍有关联广告内容,不能删除'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); }
  1081. else { for (const target of targets) target.set('zstatus', action === 'active' ? 99 : 0); await Parse.Object.saveAll(targets, { useMasterKey: true }); }
  1082. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'ad-zone-' + action, 'AdZone', target.id); return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
  1083. }
  1084. if (operation === 'adInfoBatch') {
  1085. if (String(input.className || '') !== 'AdInfo') fail(400, '只能处理广告内容'); const action = String(input.action || ''); if (!['audit','unaudit','delete'].includes(action)) fail(400, '不支持的广告内容批量操作');
  1086. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条广告内容');
  1087. const fields = await schemaFor('AdInfo'); const query = new Parse.Query('AdInfo'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分广告内容不存在或不属于当前帐套');
  1088. if (action === 'delete') await Parse.Object.destroyAll(targets, { useMasterKey: true }); else { for (const target of targets) target.set('zstatus', action === 'audit' ? 99 : 0); await Parse.Object.saveAll(targets, { useMasterKey: true }); }
  1089. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'ad-info-' + action, 'AdInfo', target.id); return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
  1090. }
  1091. if (operation === 'adPreview') {
  1092. const objectId = String(input.zoneObjectId || ''); if (!objectId) fail(400, '缺少广告位 objectId'); const zoneFields = await schemaFor('AdZone'); const zoneQuery = new Parse.Query('AdZone'); applyTenant(zoneQuery, zoneFields, context, input.companyId); const zone = await zoneQuery.get(objectId, { useMasterKey: true }); const zoneId = String(zone.get('id') || '');
  1093. const infoFields = await schemaFor('AdInfo'); const infoQuery = new Parse.Query('AdInfo'); applyTenant(infoQuery, infoFields, context, input.companyId); infoQuery.limit(1000); const now = Date.now(); let results = await infoQuery.find({ useMasterKey: true }); results = results.filter((entry) => String(entry.get('zoneId') || '') === zoneId && Number(entry.get('zstatus')) === 99 && (!entry.get('sdate') || new Date(entry.get('sdate')).getTime() <= now) && (!entry.get('edate') || new Date(entry.get('edate')).getTime() >= now)); results.sort((left, right) => Number(right.get('priority') || 0) - Number(left.get('priority') || 0) || Number(left.get('id') || 0) - Number(right.get('id') || 0));
  1094. return response.json({ success: true, data: { zone: serializeObject(zone), total: results.length, results: results.map(serializeObject) } });
  1095. }
  1096. if (operation === 'fontShapeTypes') {
  1097. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(1000, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); if (search.length > 100) fail(400, '图形分类搜索词过长');
  1098. const fields = await schemaFor('FontPicShapeType'); const query = new Parse.Query('FontPicShapeType'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => !search || [entry.get('name'),entry.get('remarks')].some((value) => String(value || '').toLowerCase().includes(search))); results.sort((left, right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
  1099. return response.json({ success: true, data: { className: 'FontPicShapeType', page, pageSize, total, results: results.map(serializeObject) } });
  1100. }
  1101. if (operation === 'fontShapes') {
  1102. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const typeId = Number(input.typeId || 0); const search = String(input.search || '').trim().toLowerCase(); if (!Number.isInteger(typeId) || typeId < 0 || search.length > 100) fail(400, '图形素材筛选条件无效');
  1103. const fields = await schemaFor('FontPicShape'); const query = new Parse.Query('FontPicShape'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => (!typeId || Number(entry.get('typeId')) === typeId) && (!search || [entry.get('name'),entry.get('remarks'),entry.get('userName')].some((value) => String(value || '').toLowerCase().includes(search)))); results.sort((left, right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
  1104. return response.json({ success: true, data: { className: 'FontPicShape', page, pageSize, total, results: results.map(serializeObject) } });
  1105. }
  1106. if (operation === 'saveFontShapeType') {
  1107. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: FontPicShapeType.id 与 Parse 保留 id 冲突,且省略 id 创建不会生成旧分类编号,不能安全新增图形分类');
  1108. const name = String(payload.name || '').trim(); const remarks = String(payload.remarks || '').trim(); if (!name || name.length > 100 || remarks.length > 2000) fail(400, '图形分类名称或备注无效');
  1109. const fields = await schemaFor('FontPicShapeType'); const query = new Parse.Query('FontPicShapeType'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); target.set('name', name); target.set('remarks', remarks); target.set('updateTime', new Date());
  1110. try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '图形分类写入失败: ' + String(error && error.message || error)); } await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-font-shape-type', 'FontPicShapeType', target.id); return response.json({ success: true, data: serializeObject(target) });
  1111. }
  1112. if (operation === 'saveFontShape') {
  1113. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: FontPicShape.id 与 Parse 保留 id 冲突,且省略 id 创建不会生成旧素材编号,不能安全新增图形素材');
  1114. if (input.file) fail(501, 'migration_blocked: 当前 Parse Server 的 SDK 与 /files REST 接口均错误调用 MongoDB 文件适配器,而本项目使用 PostgreSQL;修复服务端文件存储配置前不能替换图形文件');
  1115. const name = String(payload.name || '').trim(); const remarks = String(payload.remarks || '').trim(); const typeId = Number(input.typeId == null ? payload.typeId || 0 : input.typeId); if (!name || name.length > 100 || remarks.length > 2000 || !Number.isInteger(typeId) || typeId < 1) fail(400, '图形素材名称、分类或备注无效');
  1116. const typeFields = await schemaFor('FontPicShapeType'); const typeQuery = new Parse.Query('FontPicShapeType'); applyTenant(typeQuery, typeFields, context, input.companyId); typeQuery.limit(1000); const types = await typeQuery.find({ useMasterKey: true }); if (!types.some((entry) => Number(entry.get('id')) === typeId)) fail(404, '目标图形分类不存在或不属于当前帐套');
  1117. const fields = await schemaFor('FontPicShape'); const query = new Parse.Query('FontPicShape'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
  1118. target.set('name', name); target.set('typeId', typeId); target.set('remarks', remarks); target.set('updateTime', new Date()); try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '图形素材写入失败: ' + String(error && error.message || error)); } await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-font-shape', 'FontPicShape', target.id); return response.json({ success: true, data: serializeObject(target) });
  1119. }
  1120. if (operation === 'fontShapeTypeBatch') {
  1121. if (String(input.className || '') !== 'FontPicShapeType' || String(input.action || '') !== 'delete') fail(400, '不支持的图形分类批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个图形分类');
  1122. const fields = await schemaFor('FontPicShapeType'); const query = new Parse.Query('FontPicShapeType'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分图形分类不存在或不属于当前帐套'); const typeIds = new Set(targets.map((entry) => Number(entry.get('id'))).filter((value) => value > 0));
  1123. if (typeIds.size) { const shapeFields = await schemaFor('FontPicShape'); const refsQuery = new Parse.Query('FontPicShape'); applyTenant(refsQuery, shapeFields, context, input.companyId); refsQuery.limit(1000); const refs = await refsQuery.find({ useMasterKey: true }); if (refs.length >= 1000) fail(409, '图形素材数量超过安全核验上限,不能删除分类'); if (refs.some((entry) => typeIds.has(Number(entry.get('typeId'))))) fail(409, '图形分类仍有素材引用,不能删除'); }
  1124. await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-font-shape-type', 'FontPicShapeType', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
  1125. }
  1126. if (operation === 'fontShapeBatch') {
  1127. if (String(input.className || '') !== 'FontPicShape' || String(input.action || '') !== 'delete') fail(400, '不支持的图形素材批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个图形素材');
  1128. const fields = await schemaFor('FontPicShape'); const query = new Parse.Query('FontPicShape'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分图形素材不存在或不属于当前帐套'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-font-shape', 'FontPicShape', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
  1129. }
  1130. if (operation === 'designResources') {
  1131. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const type = String(input.type || '').trim().toLowerCase(); const search = String(input.search || '').trim().toLowerCase(); if (type.length > 32 || search.length > 100) fail(400, '设计资源筛选条件无效');
  1132. const fields = await schemaFor('DesignRes'); const query = new Parse.Query('DesignRes'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => (!type || String(entry.get('ztype') || '').toLowerCase() === type) && (!search || [entry.get('name'),entry.get('useage'),entry.get('vpath'),entry.get('previewimg')].some((value) => String(value || '').toLowerCase().includes(search)))); results.sort((left, right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
  1133. return response.json({ success: true, data: { className: 'DesignRes', page, pageSize, total, results: results.map(serializeObject) } });
  1134. }
  1135. if (operation === 'saveDesignResource') {
  1136. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1137. if (!objectId) fail(501, 'migration_blocked: 新增设计资源必须上传原文件并分配 DesignRes.id;当前 PostgreSQL Parse 文件适配器配置错误,且 id 与 Parse 保留属性冲突');
  1138. if (input.file || input.cover) fail(501, 'migration_blocked: 当前 Parse Server 的 SDK 与 /files REST 接口均错误调用 MongoDB 文件适配器,本项目使用 PostgreSQL;修复服务端文件存储前不能新增或替换设计资源文件');
  1139. const name = String(payload.name || '').trim(); const ztype = String(payload.ztype || '').trim(); const useage = String(payload.useage || '').trim(); const style = String(payload.style || ''); const use = String(payload.use || ''); const fun = String(payload.fun || ''); const zstatus = Number(payload.zstatus == null || payload.zstatus === '' ? 0 : payload.zstatus);
  1140. if (!name || name.length > 100 || !/^[a-zA-Z0-9_-]{1,32}$/.test(ztype) || useage.length > 100 || style.length > 20000 || use.length > 20000 || fun.length > 20000 || !Number.isInteger(zstatus) || zstatus < -100 || zstatus > 999) fail(400, '设计资源名称、类型、用途、状态或代码字段无效');
  1141. const fields = await schemaFor('DesignRes'); const query = new Parse.Query('DesignRes'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
  1142. target.set('name', name); target.set('ztype', ztype); target.set('useage', useage); target.set('style', style); target.set('use', use); target.set('fun', fun); target.set('zstatus', zstatus); try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '设计资源元数据写入失败: ' + String(error && error.message || error)); } await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-design-resource', 'DesignRes', target.id); return response.json({ success: true, data: serializeObject(target) });
  1143. }
  1144. if (operation === 'designResourceBatch') {
  1145. if (String(input.className || '') !== 'DesignRes' || String(input.action || '') !== 'delete') fail(400, '不支持的设计资源批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个设计资源');
  1146. const fields = await schemaFor('DesignRes'); const query = new Parse.Query('DesignRes'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分设计资源不存在或不属于当前帐套'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-design-resource', 'DesignRes', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
  1147. }
  1148. if (operation === 'roles') {
  1149. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const type = String(input.type || 'admin').trim().toLowerCase(); const search = String(input.search || '').trim().toLowerCase(); if (!['admin','user','-100'].includes(type) || search.length > 100) fail(400, '角色类型或搜索词无效');
  1150. const fields = await schemaFor('Role'); const query = new Parse.Query('Role'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => (type === '-100' || String(entry.get('ztype') || 'admin').toLowerCase() === type) && (!search || [entry.get('roleName'),entry.get('description')].some((value) => String(value || '').toLowerCase().includes(search)))); results.sort((left, right) => Number(left.get('roleId') || 0) - Number(right.get('roleId') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
  1151. return response.json({ success: true, data: { className: 'Role', page, pageSize, total, results: results.map(serializeObject) } });
  1152. }
  1153. if (operation === 'saveRole') {
  1154. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const roleName = String(payload.roleName || '').trim(); const description = String(payload.description || '').trim(); const requestedType = String(input.roleType || payload.ztype || 'admin').trim().toLowerCase(); if (!roleName || roleName.length > 100 || description.length > 1000 || !['admin','user'].includes(requestedType)) fail(400, '角色名称、说明或类型无效');
  1155. let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '角色必须指定帐套'); const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('Role'); let target; let created = false; let roleType = requestedType;
  1156. if (objectId) { const query = new Parse.Query('Role'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; roleType = String(target.get('ztype') || 'admin').toLowerCase(); if (requestedType !== roleType) fail(400, '角色类型不允许在管理员角色与用户角色之间迁移'); } else { target = new Parse.Object('Role'); target.set('company', company); target.set('sourceKey', 'cloud:role:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('ztype', roleType); target.set('zstatus', 99); target.set('nodeId', 0); target.set('auth', ''); target.set('auth2', ''); target.set('auth3', ''); target.set('cdate', new Date()); target.set('cadminId', Number(context.current.get('legacyUserId') || (context.current.get('legacyUserData') || {}).UserID || 0)); created = true; }
  1157. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Role" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("roleName",\'\')))=LOWER($3) AND LOWER(COALESCE("ztype",\'admin\'))=$4 LIMIT 1', [companyId, objectId, roleName, roleType]); if (duplicate) fail(409, '同一帐套同一类型的角色名称不能重复'); target.set('roleName', roleName); target.set('description', description);
  1158. try { await target.save(null, { useMasterKey: true }); if (created) { const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-role-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("roleId"),0)+1 AS id FROM "Role",lock_row WHERE "company"=$1) UPDATE "Role" SET "roleId"=next_id.id,"sourceKey"=\'[["RoleID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]); if (!Number(rows[0] && rows[0].id)) throw new Error('无法分配角色编号'); } } catch (error) { if (created && target.id) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '角色保存失败: ' + String(error && error.message || error)); } await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || company }, objectId ? 'update-role' : 'create-role', 'Role', target.id); return response.json({ success: true, data: serializeObject(target) });
  1159. }
  1160. if (operation === 'roleAuthorization') {
  1161. const objectId = String(input.objectId || ''); if (!objectId) fail(400, '缺少角色 objectId'); const roleFields = await schemaFor('Role'); const roleQuery = new Parse.Query('Role'); applyTenant(roleQuery, roleFields, context, input.companyId); const role = await roleQuery.get(objectId, { useMasterKey: true }); const roleId = Number(role.get('roleId')) || 0; if (!roleId) fail(409, '角色缺少旧数字 ID'); const authFields = await schemaFor('ARoleAuth'); const authQuery = new Parse.Query('ARoleAuth'); applyTenant(authQuery, authFields, { ...context, company: role.get('company') || context.company }, pointerId(role.get('company'))); authQuery.equalTo('rid', roleId); authQuery.limit(2); const authRows = await authQuery.find({ useMasterKey: true }); if (authRows.length > 1) fail(409, '角色存在重复权限记录'); const names = ['model','content','shop','page','exam','user','system','office','portable','sites','other','extend']; const authorization = authRows[0] ? serializeObject(authRows[0]) : { rid: roleId }; for (const name of names) if (!Object.prototype.hasOwnProperty.call(authorization, name)) authorization[name] = '';
  1162. return response.json({ success: true, data: { ...serializeObject(role), authorization } });
  1163. }
  1164. if (operation === 'saveRoleAuthorization') {
  1165. const roleObjectId = String(input.roleObjectId || input.objectId || ''); const payload = input.authorization && typeof input.authorization === 'object' ? input.authorization : {}; if (!roleObjectId) fail(400, '缺少角色 objectId'); const roleFields = await schemaFor('Role'); const roleQuery = new Parse.Query('Role'); applyTenant(roleQuery, roleFields, context, input.companyId); const role = await roleQuery.get(roleObjectId, { useMasterKey: true }); const roleId = Number(role.get('roleId')) || 0; const companyId = pointerId(role.get('company')) || pointerId(context.company); if (!roleId || !companyId) fail(409, '角色缺少旧编号或帐套'); const names = ['model','content','shop','page','exam','user','system','office','portable','sites','other','extend']; const normalized = {}; let totalLength = 0; for (const name of names) { const values = [...new Set(String(payload[name] || '').split(',').map((value) => value.trim()).filter(Boolean))]; if (values.length > 100 || values.some((value) => !/^[A-Za-z0-9_.:-]{1,64}$/.test(value))) fail(400, '角色权限码格式无效'); normalized[name] = values.join(','); totalLength += normalized[name].length; } if (totalLength > 10000) fail(400, '角色权限码过长');
  1166. const authFields = await schemaFor('ARoleAuth'); const authQuery = new Parse.Query('ARoleAuth'); applyTenant(authQuery, authFields, { ...context, company: role.get('company') || context.company }, companyId); authQuery.equalTo('rid', roleId); authQuery.limit(2); const rows = await authQuery.find({ useMasterKey: true }); if (rows.length > 1) fail(409, '角色存在重复权限记录'); let target = rows[0]; const created = !target; if (!target) { target = new Parse.Object('ARoleAuth'); target.set('company', role.get('company')); target.set('rid', roleId); target.set('sourceKey', 'cloud:role-auth:' + companyId + ':' + roleId + ':' + Date.now()); } for (const [name,value] of Object.entries(normalized)) target.set(name, value); target.set('adminId', Number(context.current.get('legacyUserId') || (context.current.get('legacyUserData') || {}).UserID || 0));
  1167. try { await target.save(null, { useMasterKey: true }); if (created) { const assigned = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-role-auth-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "ARoleAuth",lock_row WHERE "company"=$1) UPDATE "ARoleAuth" SET "id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]); if (!Number(assigned[0] && assigned[0].id)) throw new Error('无法分配角色权限编号'); } } catch (error) { if (created && target.id) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '角色权限保存失败: ' + String(error && error.message || error)); } await target.fetch({ useMasterKey: true }); await audit({ ...context, company: role.get('company') || context.company }, created ? 'create-role-authorization' : 'update-role-authorization', 'ARoleAuth', target.id); return response.json({ success: true, data: serializeObject(target) });
  1168. }
  1169. if (operation === 'roleBatch') {
  1170. if (String(input.className || '') !== 'Role' || String(input.action || '') !== 'delete') fail(400, '不支持的角色批量操作'); const objectIds = [...new Set((Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]).map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个角色'); const fields = await schemaFor('Role'); const query = new Parse.Query('Role'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分角色不存在或不属于当前帐套'); const authFields = await schemaFor('ARoleAuth'); const authTargets = [];
  1171. for (const target of targets) { const companyId = pointerId(target.get('company')); const roleId = Number(target.get('roleId')) || 0; if (!companyId || !roleId) fail(409, '角色缺少旧编号或帐套'); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "Manager" WHERE "company"=$1 AND POSITION(\',\'||$2::text||\',\' IN \',\'||REPLACE(COALESCE("adminRole",\'\'),\' \',\'\')||\',\')>0) AS managers,(SELECT COUNT(*)::int FROM "_User" WHERE "company"=$1 AND POSITION(\',\'||$2::text||\',\' IN \',\'||REPLACE(COALESCE("legacyUserData"->>\'UserRole\',\'\'),\' \',\'\')||\',\')>0) AS users', [companyId, roleId]); if (Number(refs.managers) || Number(refs.users)) fail(409, '角色仍被管理员或用户引用,不能删除'); const authQuery = new Parse.Query('ARoleAuth'); applyTenant(authQuery, authFields, { ...context, company: target.get('company') || context.company }, companyId); authQuery.equalTo('rid', roleId); authQuery.limit(100); authTargets.push(...await authQuery.find({ useMasterKey: true })); }
  1172. if (authTargets.length) await Parse.Object.destroyAll(authTargets, { useMasterKey: true }); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-role', 'Role', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, deletedAuthorizations: authTargets.length } });
  1173. }
  1174. if (operation === 'legacyFontPicBlocker') {
  1175. const action = String(input.action || ''); const blockers = { Index: '目标 Schema 缺少旧 FontPic 成品字体图主表与历史数据', FontPicInfo: '目标 Schema 缺少旧 FontPic 成品字体图详情数据', FontPicInfo_Submit: '目标 Schema 缺少旧 FontPic 成品字体图持久化模型', FontPic_API: '目标 Schema 缺少旧 FontPic 成品字体图可删除记录', Draft: '目标 Schema 缺少旧 FontPicDraft 草稿表与历史数据', DraftInfo: '目标 Schema 缺少旧 FontPicDraft 草稿详情数据', DraftInfo_Submit: '目标 Schema 缺少旧 FontPicDraft 草稿持久化模型', Draft_API: '目标 Schema 缺少旧 FontPicDraft 可删除记录' }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧字体图动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1176. }
  1177. if (operation === 'surveys') {
  1178. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); const status = Number(input.status == null ? -100 : input.status); if (search.length > 100 || ![-100,0,99].includes(status)) fail(400, '问卷筛选条件无效');
  1179. const fields = await schemaFor('DesignAsk'); const query = new Parse.Query('DesignAsk'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => (status === -100 || Number(entry.get('zstatus')) === status) && (!search || [entry.get('title'),entry.get('category'),entry.get('remind')].some((value) => String(value || '').toLowerCase().includes(search)))); results.sort((left, right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'DesignAsk', page, pageSize, total, results: results.map(serializeObject) } });
  1180. }
  1181. if (operation === 'surveyQuestions') {
  1182. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const askId = Number(input.askId || 0); const search = String(input.search || '').trim().toLowerCase(); if (!Number.isInteger(askId) || askId < 1 || search.length > 100) fail(400, '问卷题目筛选条件无效');
  1183. const askFields = await schemaFor('DesignAsk'); const askQuery = new Parse.Query('DesignAsk'); applyTenant(askQuery, askFields, context, input.companyId); askQuery.limit(1000); const asks = await askQuery.find({ useMasterKey: true }); if (!asks.some((entry) => Number(entry.get('id')) === askId)) fail(404, '问卷不存在或不属于当前帐套');
  1184. const fields = await schemaFor('DesignQuestion'); const query = new Parse.Query('DesignQuestion'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => Number(entry.get('askId')) === askId && (!search || [entry.get('qtitle'),entry.get('qcontent'),entry.get('qoption')].some((value) => String(value || '').toLowerCase().includes(search)))); results.sort((left, right) => Number(left.get('orderId') || 0) - Number(right.get('orderId') || 0) || Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'DesignQuestion', askId, page, pageSize, total, results: results.map(serializeObject) } });
  1185. }
  1186. if (operation === 'surveyResults') {
  1187. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const askId = Number(input.askId || 0); if (!Number.isInteger(askId) || askId < 1) fail(400, '问卷答卷筛选条件无效');
  1188. const askFields = await schemaFor('DesignAsk'); const askQuery = new Parse.Query('DesignAsk'); applyTenant(askQuery, askFields, context, input.companyId); askQuery.limit(1000); const asks = await askQuery.find({ useMasterKey: true }); if (!asks.some((entry) => Number(entry.get('id')) === askId)) fail(404, '问卷不存在或不属于当前帐套');
  1189. const fields = await schemaFor('DesignAnswer'); const query = new Parse.Query('DesignAnswer'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => Number(entry.get('askId')) === askId); results.sort((left, right) => Number(right.get('id') || 0) - Number(left.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'DesignAnswer', askId, page, pageSize, total, results: results.map(serializeObject) } });
  1190. }
  1191. if (operation === 'saveSurvey') {
  1192. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: DesignAsk.id 与 Parse 保留 id 冲突,sourceKey 创建探测不能还原旧问卷编号,不能安全新增问卷');
  1193. const title = String(payload.title || '').trim(); const category = String(payload.category || '').trim(); const remind = String(payload.remind || '').trim(); const preViewImg = String(payload.preViewImg || '').trim(); const startRaw = payload.startDate && typeof payload.startDate === 'object' ? payload.startDate.iso : payload.startDate; const endRaw = payload.endDate && typeof payload.endDate === 'object' ? payload.endDate.iso : payload.endDate; const startDate = new Date(String(startRaw || '')); const endDate = new Date(String(endRaw || '')); const ztype = Number(payload.ztype || 0); const ipinterval = Number(payload.ipinterval || 0); const zstatus = Number(payload.zstatus == null ? 0 : payload.zstatus); const flags = { isIplimit: Number(payload.isIplimit || 0), isNeedLogin: Number(payload.isNeedLogin || 0), isShowResult: Number(payload.isShowResult || 0), isEnableVcode: Number(payload.isEnableVcode || 0) };
  1194. if (!title || title.length > 200 || category.length > 100 || remind.length > 10000 || preViewImg.length > 2000 || Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate < startDate || endDate.getTime() < Date.now() || !Number.isInteger(ztype) || ztype < 0 || !Number.isFinite(ipinterval) || ipinterval < 0 || ![0,99].includes(zstatus) || Object.values(flags).some((value) => ![0,1].includes(value))) fail(400, '问卷标题、时间、状态或访问设置无效');
  1195. const fields = await schemaFor('DesignAsk'); const query = new Parse.Query('DesignAsk'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); target.set('title', title); target.set('category', category); target.set('remind', remind); target.set('preViewImg', preViewImg); target.set('startDate', startDate); target.set('endDate', endDate); target.set('ztype', ztype); target.set('ipinterval', ipinterval); target.set('zstatus', zstatus); for (const [name,value] of Object.entries(flags)) target.set(name, value); await target.save(null, { useMasterKey: true }); await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-survey', 'DesignAsk', target.id); return response.json({ success: true, data: serializeObject(target) });
  1196. }
  1197. if (operation === 'saveSurveyQuestion') {
  1198. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: DesignQuestion.id 与 Parse 保留 id 冲突,sourceKey 创建探测不能还原旧题目编号,不能安全新增问卷题目');
  1199. const qtitle = String(payload.qtitle || '').trim(); const qcontent = String(payload.qcontent || '').trim(); const qtype = String(payload.qtype || '').trim().toLowerCase(); const qoption = String(payload.qoption || '').trim(); const qflag = String(payload.qflag || '').trim(); const required = payload.required === true || payload.required === 1 || payload.required === '1' || payload.required === 'true' ? 1 : 0; if (!qtitle || qtitle.length > 1000 || qcontent.length > 20000 || !/^[a-z][a-z0-9_-]{0,30}$/.test(qtype) || qoption.length > 20000 || qflag.length > 5000) fail(400, '问卷题目内容或类型无效');
  1200. if (['radio','checkbox'].includes(qtype)) { let options; try { options = JSON.parse(qoption); } catch { fail(400, '单选/多选题选项必须是有效 JSON 数组'); } if (!Array.isArray(options) || !options.length || options.length > 100 || options.some((item) => !item || typeof item !== 'object' || !String(item.text || '').trim() || String(item.text || '').length > 500 || String(item.value || '').length > 500)) fail(400, '单选/多选题选项格式无效'); }
  1201. if (qflag) { let flag; try { flag = JSON.parse(qflag); } catch { fail(400, '题目扩展设置必须是有效 JSON'); } if (!flag || typeof flag !== 'object' || Array.isArray(flag)) fail(400, '题目扩展设置必须是 JSON 对象'); }
  1202. const fields = await schemaFor('DesignQuestion'); const query = new Parse.Query('DesignQuestion'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); const askId = Number(target.get('askId') || 0); const askFields = await schemaFor('DesignAsk'); const askQuery = new Parse.Query('DesignAsk'); applyTenant(askQuery, askFields, context, input.companyId); askQuery.limit(1000); const asks = await askQuery.find({ useMasterKey: true }); if (!asks.some((entry) => Number(entry.get('id')) === askId)) fail(409, '题目所属问卷已不存在,不能继续编辑');
  1203. target.set('qtitle', qtitle); target.set('qcontent', qcontent); target.set('qtype', qtype); target.set('qoption', qoption); target.set('qflag', qflag || '{}'); target.set('required', required); await target.save(null, { useMasterKey: true }); await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-survey-question', 'DesignQuestion', target.id); return response.json({ success: true, data: serializeObject(target) });
  1204. }
  1205. if (operation === 'surveyBatch') {
  1206. const className = String(input.className || ''); const action = String(input.action || ''); const allowed = className === 'DesignAsk' ? ['start','stop','delete'] : className === 'DesignQuestion' ? ['delete','sort'] : className === 'DesignAnswer' ? ['delete'] : []; if (!allowed.includes(action)) fail(400, '不支持的问卷批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条问卷记录');
  1207. const fields = await schemaFor(className); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分问卷记录不存在或不属于当前帐套');
  1208. if (action === 'delete') await Parse.Object.destroyAll(targets, { useMasterKey: true }); else if (className === 'DesignAsk') { for (const target of targets) target.set('zstatus', action === 'start' ? 99 : 0); await Parse.Object.saveAll(targets, { useMasterKey: true }); } else { const orders = Array.isArray(input.orders) ? input.orders : []; const orderMap = new Map(orders.map((item) => [String(item && item.objectId || ''), Number(item && item.orderId)])); if (orderMap.size !== objectIds.length || [...orderMap.values()].some((value) => !Number.isInteger(value) || value < 1 || value > 1000000)) fail(400, '题目排序数据无效'); for (const target of targets) target.set('orderId', orderMap.get(target.id)); await Parse.Object.saveAll(targets, { useMasterKey: true }); }
  1209. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'survey-' + className + '-' + action, className, target.id); return response.json({ success: true, data: { className, action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
  1210. }
  1211. if (operation === 'surveyResultChart') {
  1212. const askId = Number(input.askId || 0); if (!Number.isInteger(askId) || askId < 1) fail(400, '缺少有效问卷编号'); const askFields = await schemaFor('DesignAsk'); const askQuery = new Parse.Query('DesignAsk'); applyTenant(askQuery, askFields, context, input.companyId); askQuery.limit(1000); const asks = await askQuery.find({ useMasterKey: true }); const survey = asks.find((entry) => Number(entry.get('id')) === askId); if (!survey) fail(404, '问卷不存在或不属于当前帐套');
  1213. const questionFields = await schemaFor('DesignQuestion'); const questionQuery = new Parse.Query('DesignQuestion'); applyTenant(questionQuery, questionFields, context, input.companyId); questionQuery.limit(1000); const questions = (await questionQuery.find({ useMasterKey: true })).filter((entry) => Number(entry.get('askId')) === askId); const answerFields = await schemaFor('DesignAnswer'); const answerQuery = new Parse.Query('DesignAnswer'); applyTenant(answerQuery, answerFields, context, input.companyId); answerQuery.limit(1000); const answers = (await answerQuery.find({ useMasterKey: true })).filter((entry) => Number(entry.get('askId')) === askId); const counts = new Map(); let invalidAnswers = 0;
  1214. for (const entry of answers) { let details; try { details = JSON.parse(String(entry.get('answer') || '')); } catch { invalidAnswers++; continue; } if (!Array.isArray(details)) { invalidAnswers++; continue; } for (const detail of details) { const qid = String(detail && detail.qid || ''); let values = detail && detail.answer; if (!Array.isArray(values)) values = values == null || values === '' ? [] : [values]; for (const value of values) { const key = qid + '\u0000' + String(value); counts.set(key, (counts.get(key) || 0) + 1); } } }
  1215. const chart = questions.sort((left, right) => Number(left.get('orderId') || 0) - Number(right.get('orderId') || 0) || Number(left.get('id') || 0) - Number(right.get('id') || 0)).map((question) => { let options = []; try { const parsed = JSON.parse(String(question.get('qoption') || '[]')); if (Array.isArray(parsed)) options = parsed; } catch {} const qid = String(question.get('id') || ''); const known = new Set(options.map((option) => String(option && option.value || ''))); let otherCount = 0; for (const [key,value] of counts) if (key.startsWith(qid + '\u0000') && !known.has(key.slice(qid.length + 1))) otherCount += value; return { id: qid, title: String(question.get('qtitle') || ''), type: String(question.get('qtype') || ''), options: options.map((option) => ({ text: String(option && option.text || ''), value: String(option && option.value || ''), count: counts.get(qid + '\u0000' + String(option && option.value || '')) || 0 })), otherCount }; }); return response.json({ success: true, data: { survey: serializeObject(survey), responseCount: answers.length, invalidAnswers, questions: chart } });
  1216. }
  1217. if (operation === 'serviceSeats') {
  1218. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); if (search.length > 100) fail(400, '客服席位搜索词过长'); const fields = await schemaFor('ServiceSeat'); const query = new Parse.Query('ServiceSeat'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => !search || [entry.get('sName'),entry.get('sRemrk')].some((value) => String(value || '').toLowerCase().includes(search))); results.sort((left, right) => Number(left.get('sIndex') || 0) - Number(right.get('sIndex') || 0) || Number(left.get('sId') || 0) - Number(right.get('sId') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'ServiceSeat', page, pageSize, total, results: results.map(serializeObject) } });
  1219. }
  1220. if (operation === 'saveServiceSeat') {
  1221. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const name = String(payload.sName || '').trim(); const faceImg = String(payload.sFaceImg || '').trim(); const username = String(input.username || payload.sRemrk || '').trim(); const isDefault = Number(payload.sDefault || 0); const index = Number(payload.sIndex || 0); if (!name || name.length > 100 || faceImg.length > 2000 || !/^[A-Za-z0-9_@.\-\u4e00-\u9fff]{2,64}$/.test(username) || ![0,1].includes(isDefault) || !Number.isInteger(index) || index < 0 || index > 1000000) fail(400, '客服名称、绑定账号、默认状态或排序无效'); let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '客服席位必须指定帐套'); const userRow = await Psql.oneOrNone('SELECT "objectId",COALESCE("legacyUserId",CASE WHEN COALESCE("legacyUserData"->>\'UserID\',\'\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'UserID\')::numeric END) AS legacy_id FROM "_User" WHERE "company"=$1 AND LOWER("username")=LOWER($2) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) AND ("isDisabled" IS NULL OR "isDisabled"=FALSE) LIMIT 1', [companyId, username]); const legacyUserId = Number(userRow && userRow.legacy_id) || 0; if (!legacyUserId) fail(501, 'migration_blocked: 绑定账号不存在或缺少旧用户 ID;旧系统会用固定密码 123456 自动开户,该不安全行为不予迁移,请先通过安全开户流程创建账号'); const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('ServiceSeat'); let target; let created = false; if (objectId) { const query = new Parse.Query('ServiceSeat'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); } else { target = new Parse.Object('ServiceSeat'); target.set('sourceKey', 'cloud:service-seat:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('company', company); target.set('sDateTime', new Date().toISOString()); created = true; }
  1222. target.set('sName', name); target.set('sFaceImg', faceImg); target.set('sRemrk', username); target.set('sAdminId', legacyUserId); target.set('sDefault', isDefault); target.set('sIndex', index); try { await target.save(null, { useMasterKey: true }); if (created) { const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-service-seat-id\'))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("sId"),0)+1 AS id FROM "ServiceSeat",lock_row WHERE "company"=$1) UPDATE "ServiceSeat" SET "sId"=next_id.id,"sourceKey"=\'[["S_ID",\'||next_id.id||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]); if (!Number(rows[0] && rows[0].id)) throw new Error('无法分配客服席位编号'); } if (isDefault === 1) { const otherQuery = new Parse.Query('ServiceSeat'); applyTenant(otherQuery, fields, context, companyId); otherQuery.notEqualTo('objectId', target.id); otherQuery.limit(1000); const others = await otherQuery.find({ useMasterKey: true }); for (const other of others) if (Number(other.get('sDefault')) === 1) other.set('sDefault', 0); if (others.length) await Parse.Object.saveAll(others, { useMasterKey: true }); } } catch (error) { if (created && target.id) await target.destroy({ useMasterKey: true }).catch(() => undefined); throw error; } await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-service-seat' : 'create-service-seat', 'ServiceSeat', target.id); return response.json({ success: true, data: serializeObject(target) });
  1223. }
  1224. if (operation === 'serviceCodes') {
  1225. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); if (search.length > 100) fail(400, '欢迎语搜索词过长'); const fields = await schemaFor('Temp'); const query = new Parse.Query('Temp'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => Number(entry.get('useType')) === 12 && (!search || [entry.get('str1'),entry.get('str2'),entry.get('str4')].some((value) => String(value || '').toLowerCase().includes(search)))); results.sort((left, right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'Temp', page, pageSize, total, results: results.map(serializeObject) } });
  1226. }
  1227. if (operation === 'saveServiceCode') {
  1228. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: Temp.id 与 Parse 保留 id 冲突,省略 id 创建不能生成旧欢迎语编号,不能安全新增欢迎语模板'); const name = String(payload.str1 || '').trim(); const content = String(payload.str2 || '').trim(); const theme = String(payload.str4 || '').trim(); if (!name || name.length > 100 || !content || content.length > 50000 || !/^[A-Za-z0-9_-]{1,50}$/.test(theme)) fail(400, '欢迎语名称、内容或主题无效'); const fields = await schemaFor('Temp'); const query = new Parse.Query('Temp'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); if (Number(target.get('useType')) !== 12) fail(409, '该临时模板不是客服欢迎语'); target.set('str1', name); target.set('str2', content); target.set('str4', theme); await target.save(null, { useMasterKey: true }); await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-service-code', 'Temp', target.id); return response.json({ success: true, data: serializeObject(target) });
  1229. }
  1230. if (operation === 'serviceBatch') {
  1231. const className = String(input.className || ''); if (!['ServiceSeat','Temp'].includes(className) || String(input.action || '') !== 'delete') fail(400, '不支持的客服批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条客服记录'); const fields = await schemaFor(className); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length || (className === 'Temp' && targets.some((target) => Number(target.get('useType')) !== 12))) fail(404, '部分客服记录不存在、不属于当前帐套或不是欢迎语模板'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-service-' + (className === 'ServiceSeat' ? 'seat' : 'code'), className, target.id); return response.json({ success: true, data: { className, action: 'delete', updated: targets.length, results: [] } });
  1232. }
  1233. if (operation === 'legacyServiceChatBlocker') {
  1234. const action = String(input.action || ''); const blockers = { MsgEx: '目标 Schema 缺少旧 ChatMsg 会话消息表与历史数据', MsgInfo: '目标 Schema 缺少旧 ChatMsg 单条消息详情数据', MsgList: '目标 Schema 缺少旧 ChatMsg 消息列表数据', chat_del: '目标 Schema 缺少旧 ChatMsg 可删除记录' }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧客服消息动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1235. }
  1236. if (operation === 'storeApplications') {
  1237. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); const status = Number(input.status == null ? -100 : input.status); if (search.length > 100 || ![-100,-1,0,99].includes(status)) fail(400, '店铺筛选条件无效'); const fields = await schemaFor('StoreApplication'); const query = new Parse.Query('StoreApplication'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => (status === -100 || Number(entry.get('storeState')) === status) && (!search || [entry.get('storeName'),entry.get('userName'),entry.get('lxr'),entry.get('tel'),entry.get('addr')].some((value) => String(value || '').toLowerCase().includes(search)))); results.sort((left, right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'StoreApplication', page, pageSize, total, results: results.map(serializeObject) } });
  1238. }
  1239. if (operation === 'storeStyles') {
  1240. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); if (search.length > 100) fail(400, '店铺样式搜索词过长'); const fields = await schemaFor('StoreStyle'); const query = new Parse.Query('StoreStyle'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => !search || [entry.get('styleName'),entry.get('remind'),entry.get('templateIndex'),entry.get('templateContent'),entry.get('templateList')].some((value) => String(value || '').toLowerCase().includes(search))); results.sort((left, right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'StoreStyle', page, pageSize, total, results: results.map(serializeObject) } });
  1241. }
  1242. if (operation === 'saveStoreApplication') {
  1243. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: StoreApplication.id 与 Parse 保留 id 冲突,且旧店铺创建还需要已丢失的模型 6 动态字段写入流程,不能安全新增店铺'); const storeName = String(payload.storeName || '').trim(); const username = String(input.username || payload.userName || '').trim(); const styleId = Number(input.styleId == null ? payload.storeStyleId || 0 : input.styleId); if (!storeName || storeName.length > 200 || !/^[A-Za-z0-9_@.\-\u4e00-\u9fff]{2,64}$/.test(username) || !Number.isInteger(styleId) || styleId < 0) fail(400, '店铺名称、所属用户或样式无效'); let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '店铺必须指定帐套'); const userRow = await Psql.oneOrNone('SELECT COALESCE("legacyUserId",CASE WHEN COALESCE("legacyUserData"->>\'UserID\',\'\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'UserID\')::numeric END) AS legacy_id FROM "_User" WHERE "company"=$1 AND LOWER("username")=LOWER($2) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1', [companyId, username]); const userId = Number(userRow && userRow.legacy_id) || 0; if (!userId) fail(404, '店铺所属用户不存在、缺少旧用户 ID 或不属于当前帐套'); if (styleId) { const styleFields = await schemaFor('StoreStyle'); const styleQuery = new Parse.Query('StoreStyle'); applyTenant(styleQuery, styleFields, context, input.companyId); styleQuery.limit(1000); const styles = await styleQuery.find({ useMasterKey: true }); if (!styles.some((entry) => Number(entry.get('id')) === styleId)) fail(404, '店铺样式不存在或不属于当前帐套'); }
  1244. const fields = await schemaFor('StoreApplication'); const query = new Parse.Query('StoreApplication'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); const strings = { storeName, lxr: String(payload.lxr || '').trim(), tel: String(payload.tel || '').trim(), addr: String(payload.addr || '').trim(), area: String(payload.area || '').trim(), logo: String(payload.logo || '').trim(), pics: String(payload.pics || '').trim(), weibo: String(payload.weibo || '').trim(), map: String(payload.map || '').trim(), content: String(payload.content || '').trim(), synopsis: String(payload.synopsis || '').trim(), shopType: String(payload.shopType || '').trim(), videoUrl: String(payload.videoUrl || '').trim() }; if (Object.values(strings).some((value) => value.length > 50000) || strings.tel.length > 100 || strings.lxr.length > 100 || strings.area.length > 500 || strings.addr.length > 2000 || strings.logo.length > 2000 || strings.videoUrl.length > 2000) fail(400, '店铺资料字段超过允许长度'); for (const [name,value] of Object.entries(strings)) target.set(name, value); target.set('userId', userId); target.set('userName', username); target.set('storeStyleId', styleId); await target.save(null, { useMasterKey: true }); await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-store-application', 'StoreApplication', target.id); return response.json({ success: true, data: serializeObject(target) });
  1245. }
  1246. if (operation === 'saveStoreStyle') {
  1247. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: StoreStyle.id 与 Parse 保留 id 冲突,省略 id 创建不能生成旧样式编号,不能安全新增店铺样式'); const styleName = String(payload.styleName || '').trim(); const remind = String(payload.remind || '').trim(); const thumbnail = String(payload.thumbnail || '').trim(); const templateIndex = String(payload.templateIndex || '').trim(); const templateContent = String(payload.templateContent || '').trim(); const templateList = String(payload.templateList || '').trim(); if (!styleName || styleName.length > 200 || remind.length > 5000 || thumbnail.length > 2000 || !templateIndex || !templateContent || !templateList || [templateIndex,templateContent,templateList].some((value) => value.length > 2000 || /[<>\r\n]/.test(value))) fail(400, '店铺样式名称、模板路径、缩略图或说明无效'); const fields = await schemaFor('StoreStyle'); const query = new Parse.Query('StoreStyle'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); target.set('styleName', styleName); target.set('remind', remind); target.set('thumbnail', thumbnail); target.set('templateIndex', templateIndex); target.set('templateContent', templateContent); target.set('templateList', templateList); await target.save(null, { useMasterKey: true }); await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-store-style', 'StoreStyle', target.id); return response.json({ success: true, data: serializeObject(target) });
  1248. }
  1249. if (operation === 'storeBatch') {
  1250. const className = String(input.className || ''); const action = String(input.action || ''); const allowed = className === 'StoreApplication' ? ['audit','unaudit','elite','unelite','delete'] : className === 'StoreStyle' ? ['delete'] : []; if (!allowed.includes(action)) fail(400, '不支持的店铺批量操作'); if (className === 'StoreApplication' && action === 'delete') fail(501, 'migration_blocked: 旧 ZL_Commodities 商品主表缺失,无法核验店铺是否仍有关联商品,不能安全真实删除店铺'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条店铺记录'); const fields = await schemaFor(className); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分店铺记录不存在或不属于当前帐套'); if (className === 'StoreStyle') { const styleIds = new Set(targets.map((target) => Number(target.get('id')))); const storeFields = await schemaFor('StoreApplication'); const refsQuery = new Parse.Query('StoreApplication'); applyTenant(refsQuery, storeFields, context, input.companyId); refsQuery.limit(1000); const refs = await refsQuery.find({ useMasterKey: true }); if (refs.some((entry) => styleIds.has(Number(entry.get('storeStyleId'))))) fail(409, '店铺样式仍被店铺引用,不能删除'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); } else { for (const target of targets) { if (action === 'audit') target.set('storeState', 99); if (action === 'unaudit') target.set('storeState', -1); if (action === 'elite') { target.set('storeState', 99); target.set('storeCommendState', 1); } if (action === 'unelite') target.set('storeCommendState', 0); } await Parse.Object.saveAll(targets, { useMasterKey: true }); } for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'store-' + action, className, target.id); return response.json({ success: true, data: { className, action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
  1251. }
  1252. if (operation === 'legacyStoreProductBlocker') fail(501, 'migration_blocked: 旧 Product 页面依赖 ZL_Commodities 商品主表;目标仅有 Product/StoreProduct 扩展碎片,缺少商品名称、店铺归属、审核/销售状态与节点关系,无法等价重建列表');
  1253. if (operation === 'schema' && String(input.className || '') === 'ContentTagKey') {
  1254. return response.json({ success: true, data: { className: 'ContentTagKey', label: CLASS_LABELS.ContentTagKey, fields: [{ name: 'objectId', type: 'String', writable: false }, { name: 'count', type: 'Number', writable: false }, { name: 'content', type: 'String', writable: false }], writable: true, creatable: false, supportsSoftDelete: false } });
  1255. }
  1256. if (operation === 'contentTagKeys' || operation === 'getContentTagKeys') {
  1257. const tags = await readContentTags(); const record = { objectId: 'global-content-tags', count: tags.length, content: tags.join('|'), tags }; const search = String(input.search || '').trim().toLowerCase(); const visible = !search || tags.some((tag) => tag.toLowerCase().includes(search));
  1258. if (operation === 'getContentTagKeys') { if (String(input.objectId || '') !== record.objectId) fail(404, '内容标签词库不存在'); return response.json({ success: true, data: record }); }
  1259. return response.json({ success: true, data: { className: 'ContentTagKey', page: 1, pageSize: 20, total: visible ? 1 : 0, results: visible ? [record] : [] } });
  1260. }
  1261. if (operation === 'saveContentTagKeys') {
  1262. if (String(input.objectId || '') !== 'global-content-tags') fail(400, '内容标签词库只有一份全局配置'); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const source = input.tags == null ? (input.content == null ? payload.content : input.content) : input.tags; const tags = normalizeContentTags(source); await Parse.Config.save({ legacyContentTags: tags }, { useMasterKey: true }); await audit(context, 'update-content-tag-keys', 'ContentTagKey', 'global-content-tags'); return response.json({ success: true, data: { objectId: 'global-content-tags', count: tags.length, content: tags.join('|'), tags } });
  1263. }
  1264. if (operation === 'legacyContentAddonBlocker') {
  1265. const action = String(input.action || ''); const blockers = {
  1266. Comment: '目标 Schema 缺少旧 ZL_Comment 评论主表与历史评论数据',
  1267. Commont_API: '目标 Schema 缺少可审核、取消审核或删除的旧 ZL_Comment 评论记录',
  1268. ConAudit_API: '目标 Schema 缺少 ContentAudit 审核记录、节点审核流程与管理员步骤状态',
  1269. ConAudit_SJ_API: '目标 Schema 缺少 ContentAudit 送审确认/驳回记录和可追溯流程状态',
  1270. ConAudit: '目标 Schema 缺少旧 ZL_Content_Audit 审核列表及历史数据',
  1271. ConAuditDetail: '目标 Schema 缺少内容审核明细、备注、节点和操作人流转记录',
  1272. VerBak: '目标 Schema 缺少旧 ZL_Content_VerBak 内容/商品版本快照与历史数据',
  1273. VerBak_Del: '目标 Schema 缺少可定位和删除的旧 ZL_Content_VerBak 版本记录'
  1274. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的内容附加功能动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1275. }
  1276. if (operation === 'legacyShopVendorBlocker') {
  1277. const action = String(input.action || ''); const blockers = {
  1278. Vendor: '目标 Schema 缺少旧 ZL_Shop_Vendor 供应商主表与历史供应商数据',
  1279. Vendor_API: '目标没有可定位和删除的供应商记录;Agency/DeliveryCenter 与供应商语义不等价',
  1280. VendorAdd: '目标 Schema 没有可承载供应商名称、地址、手机、传真、邮箱和状态的签约数据类',
  1281. VendorAdd_Submit: '按既定数据库设计不能擅自新建 ShopVendor 类,也不能把供应商字段错写到 Agency 或 DeliveryCenter'
  1282. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧供应商动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1283. }
  1284. if (operation === 'userLevels') {
  1285. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); if (search.length > 100) fail(400, '积分等级搜索词过长'); const fields = await schemaFor('UserLevel'); const query = new Parse.Query('UserLevel'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => !search || [entry.get('alias'),entry.get('remark')].some((value) => String(value || '').toLowerCase().includes(search))); results.sort((left,right) => Number(left.get('orderId') || left.get('id') || 0) - Number(right.get('orderId') || right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'UserLevel', page, pageSize, total, results: results.map(serializeObject) } });
  1286. }
  1287. if (operation === 'saveUserLevel') {
  1288. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: UserLevel.id 与 Parse 保留 id 冲突,兼容层无法为新积分等级生成旧数字 ID'); const alias = String(payload.alias || '').trim(); const pointRate = Number(payload.pointRate || 0); const image = String(payload.image || '').trim(); const remark = String(payload.remark || '').trim(); if (!alias || alias.length > 100 || !Number.isInteger(pointRate) || pointRate < 0 || pointRate > 1000000000 || image.length > 2000 || remark.length > 5000) fail(400, '积分等级名称、阈值、图片或备注无效'); const fields = await schemaFor('UserLevel'); const query = new Parse.Query('UserLevel'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); const companyId = pointerId(target.get('company')) || pointerId(context.company); const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "UserLevel" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("alias",\'\')))=LOWER($3) LIMIT 1', [companyId, objectId, alias]); if (duplicate) fail(409, '同一帐套的积分等级名称不能重复'); target.set('alias', alias); target.set('pointRate', pointRate); target.set('image', image); target.set('remark', remark); await target.save(null, { useMasterKey: true }); await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-user-level', 'UserLevel', target.id); return response.json({ success: true, data: serializeObject(target) });
  1289. }
  1290. if (operation === 'userLevelBatch') {
  1291. if (String(input.action || '') !== 'delete') fail(400, '不支持的积分等级批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个积分等级'); const fields = await schemaFor('UserLevel'); const query = new Parse.Query('UserLevel'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分积分等级不存在或不属于当前帐套'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-user-level', 'UserLevel', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
  1292. }
  1293. if (operation === 'schema' && String(input.className || '') === 'UserMoneyLog') {
  1294. return response.json({ success: true, data: { className: 'UserMoneyLog', label: CLASS_LABELS.UserMoneyLog, fields: ['objectId','moneyTypeLabel','userId','userName','score','scoreBefore','detail','remark','operator','hisTime','ledgerClass'].map((name) => ({ name, type: name === 'userId' || name === 'score' || name === 'scoreBefore' ? 'Number' : name === 'hisTime' ? 'Date' : 'String', writable: false })), writable: false, creatable: false, supportsSoftDelete: false } });
  1295. }
  1296. if (operation === 'userMoneyLogs' || operation === 'getUserMoneyLog') {
  1297. const compositeId = String(input.objectId || ''); let type = Number(input.type || 1); let recordObjectId = ''; if (operation === 'getUserMoneyLog') { const match = compositeId.match(/^([A-Za-z][A-Za-z0-9_]*):(.+)$/); if (!match) fail(400, '资金流水标识无效'); type = Number(Object.entries(USER_MONEY_LOG_CLASSES).find(([,name]) => name === match[1])?.[0] || 0); recordObjectId = match[2]; } const ledgerClass = USER_MONEY_LOG_CLASSES[type]; if (!ledgerClass) fail(400, '资金流水类型必须为 1 至 6'); const fields = await schemaFor(ledgerClass); const query = new Parse.Query(ledgerClass); applyTenant(query, fields, context, input.companyId); if (operation === 'getUserMoneyLog') { const entry = await query.get(recordObjectId, { useMasterKey: true }); const row = serializeObject(entry); row.recordObjectId = entry.id; row.objectId = ledgerClass + ':' + entry.id; row.ledgerClass = ledgerClass; row.moneyType = type; row.moneyTypeLabel = USER_MONEY_LOG_LABELS[type]; return response.json({ success: true, data: row }); }
  1298. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); if (search.length > 100) fail(400, '资金流水搜索词过长'); const start = input.startDate ? new Date(String(input.startDate) + 'T00:00:00.000Z') : null; const end = input.endDate ? new Date(String(input.endDate) + 'T23:59:59.999Z') : null; if ((start && Number.isNaN(start.getTime())) || (end && Number.isNaN(end.getTime())) || (start && end && start > end)) fail(400, '资金流水日期范围无效'); query.limit(1000); let entries = await query.find({ useMasterKey: true }); if (start) entries = entries.filter((entry) => new Date(entry.get('hisTime')).getTime() >= start.getTime()); if (end) entries = entries.filter((entry) => new Date(entry.get('hisTime')).getTime() <= end.getTime()); const userFields = await schemaFor('_User'); const userQuery = new Parse.Query('_User'); applyTenant(userQuery, userFields, context, input.companyId); userQuery.limit(1000); const users = await userQuery.find({ useMasterKey: true }); const userNames = new Map(users.map((entry) => [Number(entry.get('legacyUserId') || (entry.get('legacyUserData') || {}).UserID || 0), String(entry.get('username') || '')])); let rows = entries.map((entry) => { const row = serializeObject(entry); const userId = Number(entry.get('userId') || 0); row.recordObjectId = entry.id; row.objectId = ledgerClass + ':' + entry.id; row.ledgerClass = ledgerClass; row.moneyType = type; row.moneyTypeLabel = USER_MONEY_LOG_LABELS[type]; row.userName = userNames.get(userId) || ''; return row; }); rows = rows.filter((row) => !search || [row.userId,row.userName,row.detail,row.remark,row.operator].some((value) => String(value || '').toLowerCase().includes(search))); rows.sort((left,right) => new Date(String(right.hisTime?.iso || right.hisTime || 0)).getTime() - new Date(String(left.hisTime?.iso || left.hisTime || 0)).getTime()); const total = rows.length; rows = rows.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'UserMoneyLog', page, pageSize, total, results: rows } });
  1299. }
  1300. if (operation === 'schema' && String(input.className || '') === 'UserPromotion') {
  1301. const fields = [
  1302. ['objectId','String'],['userId','Number'],['userName','String'],['honeyName','String'],['parentUserId','Number'],['parentUserName','String'],['parentHoneyName','String'],['promotionCount','Number'],['regTime','Date']
  1303. ].map(([name,type]) => ({ name, type, writable: false }));
  1304. return response.json({ success: true, data: { className: 'UserPromotion', label: CLASS_LABELS.UserPromotion, fields, writable: false, creatable: false, supportsSoftDelete: false } });
  1305. }
  1306. if (operation === 'userPromotions' || operation === 'getUserPromotion') {
  1307. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); const parentUserId = Number(input.parentUserId || 0); const onlyWithChildren = input.onlyWithChildren === true || String(input.onlyWithChildren || '') === '1'; const startDate = String(input.startDate || '').trim(); const endDate = String(input.endDate || '').trim();
  1308. if (search.length > 100 || !Number.isInteger(parentUserId) || parentUserId < 0 || (startDate && !/^\d{4}-\d{2}-\d{2}$/.test(startDate)) || (endDate && !/^\d{4}-\d{2}-\d{2}$/.test(endDate))) fail(400, '推广关系筛选条件无效'); const start = startDate ? new Date(startDate + 'T00:00:00+08:00') : null; const end = endDate ? new Date(new Date(endDate + 'T00:00:00+08:00').getTime() + 86400000) : null; if ((start && Number.isNaN(start.getTime())) || (end && Number.isNaN(end.getTime())) || (start && end && start >= end)) fail(400, '推广关系日期范围无效'); let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '推广关系必须指定帐套');
  1309. const raw = await Psql.query('SELECT "objectId","username","realName","nickname","legacyUserId","legacyUserData","createdAt" FROM "_User" WHERE "company"=$1 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)', [companyId]); const normalized = raw.map((entry) => { let legacy = entry.legacyUserData || {}; if (typeof legacy === 'string') { try { legacy = JSON.parse(legacy); } catch (_) { legacy = {}; } } const userId = Number(entry.legacyUserId || legacy.UserID || 0); const rawRegTime = legacy.RegTime || entry.createdAt; const parsedRegTime = rawRegTime ? new Date(rawRegTime) : null; return { objectId:String(entry.objectId || ''), userId, userName:String(entry.username || legacy.UserName || ''), honeyName:String(entry.realName || entry.nickname || legacy.HoneyName || entry.username || ''), parentUserId:Number(legacy.ParentUserID || 0), regTime:parsedRegTime && !Number.isNaN(parsedRegTime.getTime()) ? parsedRegTime.toISOString() : null }; }).filter((entry) => entry.objectId && entry.userId > 0); const byId = new Map(normalized.map((entry) => [entry.userId,entry])); const counts = new Map(); for (const entry of normalized) { if (!entry.parentUserId) continue; const time = entry.regTime ? new Date(entry.regTime).getTime() : NaN; if ((start && (Number.isNaN(time) || time < start.getTime())) || (end && (Number.isNaN(time) || time >= end.getTime()))) continue; counts.set(entry.parentUserId, Number(counts.get(entry.parentUserId) || 0) + 1); }
  1310. let rows = normalized.map((entry) => { const parent = byId.get(entry.parentUserId); return { ...entry, parentUserName:parent?.userName || '', parentHoneyName:parent?.honeyName || '', promotionCount:Number(counts.get(entry.userId) || 0) }; }); if (operation === 'getUserPromotion') { const objectId = String(input.objectId || ''); const row = rows.find((entry) => entry.objectId === objectId); if (!row) fail(404, '推广用户不存在或不属于当前帐套'); return response.json({ success: true, data: row }); } rows = rows.filter((entry) => (!parentUserId || entry.parentUserId === parentUserId) && (!onlyWithChildren || entry.promotionCount > 0) && (!search || [entry.userName,entry.honeyName,entry.parentUserName,entry.parentHoneyName,entry.userId,entry.parentUserId].some((value) => String(value || '').toLowerCase().includes(search)))); rows.sort((left,right) => right.promotionCount - left.promotionCount || right.userId - left.userId); const total = rows.length; rows = rows.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className:'UserPromotion', page, pageSize, total, results:rows } });
  1311. }
  1312. if (operation === 'legacyUserAddonBlocker') {
  1313. const action = String(input.action || ''); const blockers = {
  1314. ThirdInfo: '旧 ThirdInfo.config 的 Baidu/QQ/Sina/BaiduTrans/Wechat/Printer 全局配置与历史密钥未迁入目标数据库',
  1315. ThirdInfo_Submit: '目标 ThirdPlatInfo 只有版权印、飞印打印、百度翻译、头条号等不同业务平台,且云函数无旧 XML/OAuth 运行时消费者,不能伪造保存生效',
  1316. SigninList: '目标 Schema 缺少 PrizeTaskLog 及 rids=Global.SignIn 的用户签到任务日志;MisSign 是工作签到配置而非用户签到历史',
  1317. RealNameAuth: '目标 Schema 缺少 UserRealNameAuth 申请主表、审核状态与历史数据',
  1318. RealNameAuthAdd: '目标 Profile 中零散身份字段不等价于实名认证申请,缺少可审阅的申请详情',
  1319. RealNameAuth_API: '目标缺少实名申请记录及 Global.RealNameAuth 任务奖励链,无法安全审核、取消审核或删除'
  1320. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧用户附加功能动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1321. }
  1322. if (operation === 'legacyAskBlocker') {
  1323. const action = String(input.action || ''); const blockers = {
  1324. Default: '目标 Schema 缺少旧 ZL_Ask 问题主表、回收站标记与历史问题数据',
  1325. AskInfo: '目标无可定位的问答问题详情;Baike/Guestbook/GuestBar/DesignQuestion 均非等价问题主表',
  1326. AskAdd: '目标无 Ask 数据类承载 QTitle/Qcontent 与问答状态、精华、回收站关系',
  1327. AskAdd_Submit: '按既定数据库设计不能把问题错写到百科、留言、论坛或问卷题目',
  1328. Ask_API: '目标缺少可回收、恢复、删除、审核或加精的旧问答问题记录',
  1329. AnswerList: '目标 Schema 缺少旧 ZL_GuestAnswer 回答主表及 QueId 问题外键',
  1330. AnswerAdd: '目标无可编辑或新增的问答回答记录与问题归属',
  1331. AnswerAdd_Submit: '目标缺少 GuestAnswer 的 QueId/UserId/UserName/Content 数据模型与历史关系',
  1332. Answer_API: '目标缺少可审核、取消审核或删除的旧问答回答记录',
  1333. Config: '目标没有 GuestConfig.WDOption 问答运行时和 SelGroup/ReplyGroup/QuestGroup 权限消费者',
  1334. Config_Submit: '即使存储积分与用户组参数,目标也没有问答发布/回复/推荐运行时执行这些规则,不能伪造保存生效'
  1335. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧问答动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1336. }
  1337. if (operation === 'legacyBiaoCalcBlocker') {
  1338. const action = String(input.action || ''); const blockers = {
  1339. Default: '目标 Schema 缺少旧 BiaoCalcModel 裱件模型主表、状态与历史记录',
  1340. BiaoDetail: '目标无可定位的裱件详情及其 UserID 用户关系',
  1341. Biao_API: '目标缺少可启用、停用或删除的 BiaoCalcModel 裱件记录',
  1342. UserFiles: '目标 Schema 缺少旧 BiaoCalcUserFiles 用户文件主表、等级、到期时间与历史授权记录',
  1343. UserFiles_API: '目标无 BiaoCalcUserFiles.ExpireTime 可安全执行到期或按年月日续期;账户会员到期时间语义不等价'
  1344. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧裱件计算动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1345. }
  1346. if (operation === 'legacyOfficeChartBlocker') {
  1347. if (String(input.action || '') !== 'Index') fail(400, '不支持的旧 Office 图表动作');
  1348. fail(501, 'migration_blocked: 旧 Office/Chart.Index 只保留了返回视图的空壳控制器,交付包与官方源码均缺少对应 Index.cshtml 视图、数据源和交互定义,无法 1:1 复原');
  1349. }
  1350. if (operation === 'crmClientTypes') {
  1351. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); if (search.length > 100) fail(400, '客户类型搜索词不能超过 100 个字符'); const fields = await schemaFor('CRMSAttr'); const query = new Parse.Query('CRMSAttr'); applyTenant(query, fields, context, input.companyId); query.equalTo('ztype', 'ctype'); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((target) => !search || [target.get('value'),target.get('remark')].some((value) => String(value || '').toLowerCase().includes(search))); results.sort((left,right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'CRMSAttr', page, pageSize, total, results: results.map(serializeObject) } });
  1352. }
  1353. if (operation === 'saveCrmClientType') {
  1354. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const value = String(payload.value || '').trim(); const remark = String(payload.remark || '').trim(); if (!value || value.length > 100 || remark.length > 1000) fail(400, '客户类型名称长度应为 1 至 100 位,备注不能超过 1000 位'); let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '客户类型必须指定帐套'); const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('CRMSAttr'); let target; let created = false; if (objectId) { const query = new Parse.Query('CRMSAttr'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); if (String(target.get('ztype') || '').toLowerCase() !== 'ctype') fail(409, '该 CRM 属性不是客户类型'); companyId = pointerId(target.get('company')) || companyId; } else { target = new Parse.Object('CRMSAttr'); target.set('sourceKey', 'cloud:crm-client-type:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('company', company); target.set('ztype', 'ctype'); target.set('zstatus', 0); target.set('cdate', new Date()); created = true; }
  1355. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "CRMSAttr" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("value",\'\')))=LOWER($3) AND LOWER(COALESCE("ztype",\'\'))=\'ctype\' LIMIT 1', [companyId, objectId, value]); if (duplicate) fail(409, '同一帐套的客户类型名称不能重复'); target.set('value', value); target.set('remark', remark); try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '客户类型初始写入失败: ' + String(error && error.message || error)); }
  1356. try { if (created) { const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-crm-client-type-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "CRMSAttr",lock_row WHERE "company"=$1) UPDATE "CRMSAttr" SET "id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]); if (!(Number(rows[0] && rows[0].id) || 0)) throw new Error('无法分配客户类型编号'); } } catch (error) { if (created) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '客户类型编号写入失败: ' + String(error && error.message || error)); }
  1357. await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || company }, objectId ? 'update-crm-client-type' : 'create-crm-client-type', 'CRMSAttr', target.id); return response.json({ success: true, data: serializeObject(target) });
  1358. }
  1359. if (operation === 'crmClientTypeBatch') {
  1360. if (String(input.action || '') !== 'delete') fail(400, '不支持的客户类型批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个客户类型'); const fields = await schemaFor('CRMSAttr'); const query = new Parse.Query('CRMSAttr'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length || targets.some((target) => String(target.get('ztype') || '').toLowerCase() !== 'ctype')) fail(404, '部分客户类型不存在或不属于当前帐套'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-crm-client-type', 'CRMSAttr', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length } });
  1361. }
  1362. if (operation === 'legacyCrmBlocker') {
  1363. const action = String(input.action || ''); const blockers = {
  1364. Index: '目标 Schema 缺少旧 CRMS_Client 客户主表,无法进入客户列表首页', ClientList: '目标 Schema 缺少旧 CRMS_Client 客户主表与 Model 48 动态附表', ClientView: '目标缺少客户、联系人、服务记录和动态附表的联合详情数据', ClientAdd: '目标没有可编辑或新增的 CRMS_Client 客户记录', BiServer_Del: '目标 Schema 缺少旧 IServer 服务记录主表', Client_Add: '目标缺少 CRMS_Client 与 Model 48 动态附表原子写入链', ClientImport: '目标缺少客户主表,且旧 CRM_Client.xml 导入定义未迁入', Import_DownTlp: '交付包没有旧 CRM_Client.xml 字段映射,无法等价生成 xlsx 模板', Import_Client: '目标缺少 CRMS_Client/Model 48 写入结构及旧 XML+NPOI 导入流水线', AddServiceRecord: '目标 Schema 缺少旧 IServer 客户服务记录主表', AddServiceRecord_Submit: '目标无法保存 crm_ID、管理员、用户、优先级、类型、状态、附件和提醒等服务记录', ServiceRecord_List: '目标没有可查询的旧 IServer 服务记录', Contact: '目标 Schema 缺少旧 CRMS_Contact 联系人主表;ContactInfo 是企业微信联系人,语义不等价', ContactAdd: '目标无可编辑或新增的 CRMS_Contact 客户联系人记录', Contact_Add: '不能把旧 CRM 联系人错写到企业微信 ContactInfo', ContactImport: '目标缺少 CRM 联系人主表,且旧 CRM_Contact.xml 导入定义未迁入', ContactImport_Down: '交付包没有旧 CRM_Contact.xml 字段映射,无法等价生成 xlsx 模板', ContactImport_Upload: '目标缺少 CRMS_Contact 写入结构及旧 XML+NPOI 导入流水线', BecomeCustomerList: '目标 Schema 缺少 user_becustomer 类型 Common_UserApply 经销商申请', BecomeCustomerDetail: '目标缺少经销商申请、申请用户和资质详情数据', BecomeCustomerDetailUpdate: '目标没有可更新的经销商申请 Info1/AdminRemind 记录', BecomeCustomer_API: '目标缺少申请、LicenceFiles/LicenceUserSign、CRM 客户及 PDF 文件运行时,无法执行审核链'
  1365. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 CRM 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1366. }
  1367. if (operation === 'legacyCounterBlocker') {
  1368. const action = String(input.action || ''); const blockers = {
  1369. 'Design/VisitList': '目标 Schema 缺少旧 ZL_Com_VisitCount 访问明细表及 h5 访问记录',
  1370. 'Design/VisitInfoList': '目标没有可按 InfoID 聚合的 h5 访问明细和最近访问记录',
  1371. 'Design/VisitInfoDetail': '目标缺少指定 InfoID 的访问时间、IP、浏览器、系统、地域和来源明细',
  1372. 'Design/Counter_API': '目标没有可定位并删除的旧访问统计记录',
  1373. 'Extend/StatisticalCode': '目标缺少 SiteInfo.AllSiteJS 配置模型及在所有页面注入统计脚本的运行时',
  1374. 'Extend/StatisticalCode_Submit': '云函数不能仅保存任意全站脚本而没有可信的前端注入、CSP 与版本发布链',
  1375. 'Extend/Index': '目标 Schema 缺少 ZL_Com_VisitCount 数据,无法生成旧访问统计首页',
  1376. 'Extend/Site': '目标缺少全站访问明细与总访问计数',
  1377. 'Extend/Month': '目标缺少可按年月聚合的访问日期明细',
  1378. 'Extend/Year': '目标缺少可按年度和月份聚合的访问日期明细',
  1379. 'Extend/Local': '目标缺少访客地域 Address 明细',
  1380. 'Extend/Browser': '目标缺少访客浏览器标识与访问明细',
  1381. 'Extend/Os': '目标缺少访客操作系统标识与访问明细',
  1382. 'Extend/Channel': '目标缺少访问来源渠道与 Referer 明细'
  1383. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧访问统计动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1384. }
  1385. if (operation === 'legacyEChartsBlocker') {
  1386. const action = String(input.action || ''); const blockers = {
  1387. EChartList: '目标 Schema 缺少旧 Content_Chart 图表定义主表与历史图表',
  1388. ChartCite: '目标缺少图表名称、站点 SiteUrl 和 /Common/ShowM 公开渲染链,无法生成旧 iframe/Markdown 引用',
  1389. ShowM: '目标没有 Content_Chart option/package 数据与旧 ECharts ShowM 渲染页面',
  1390. AddChart: '目标缺少 Content_Chart 持久层和旧 Bar/Pie/Dash/Funnel/Scatter/Circle/Map 配置生成运行时',
  1391. AddChart_Submit: '目标无法持久化 TName、ChartTitle、ToolBox、Package、option、SType 与 Tag 图表定义',
  1392. Default: '目标没有可按 SType 筛选分页的旧 Content_Chart 记录'
  1393. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 ECharts 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1394. }
  1395. if (operation === 'legacyExamBlocker') {
  1396. const action = String(input.action || ''); const blockers = {
  1397. AddEngLishQuestion: '旧英语试题页依赖复合小题、音频/图片上传和答案解析器;目标仅保留只读 ExamSysQuestions 记录',
  1398. Setting: '目标 Schema 缺少旧 Exam_Sys_Answer 答卷明细,无法按最近一周统计并重算试题难度',
  1399. VersionList: '目标 Schema 缺少旧 Exam_Version 教材版本/册/章节树与历史数据', AddVersion: '目标无可定位或新建的 Exam_Version 版本树记录',
  1400. SchoolManage: '目标 Schema 缺少旧 School 学校主表与省市县数据', AddSchool: '目标无可编辑或新增的 School 记录',
  1401. ClassRoomManage: '目标 Schema 缺少旧 ClassRoom 班级主表与学校、年级、教师关系', AddClassRoom: '目标无可编辑或新增的 ClassRoom 记录',
  1402. StudentList: '目标 Schema 缺少 ClassRoomStudent 班级学员关系、审核状态与学员类型',
  1403. CourseManage: '目标 Schema 缺少旧 Course 课程主表;CourseBinding 是小树课程绑定,语义不等价', AddCourse: '目标无旧 Course 名称、科目、教师与课程结构记录',
  1404. ToScore: '目标 Schema 缺少旧 Exam_Sys_Answer 主观题答卷与评分状态',
  1405. Papers_Add: '试卷写入必须原子维护 qids/questList、分类、计时、价格与题目分值;当前只读迁移不允许通用表单破坏结构',
  1406. Question_Add: '旧试题写入依赖题型答案解析、复合小题、知识点和上传资源;当前只读迁移不能安全保存',
  1407. Version_GetList: '目标缺少可按 pid 查询的 Exam_Version 版本树', Version_Add: '目标缺少 Exam_Version 版本、册、章节持久层',
  1408. School_Add: '目标缺少 School 主表,无法保存学校类型、地区、校徽和状态', ClassRoom_Add: '目标缺少 ClassRoom 与学校、教师、年级关系持久层', Course_Add: '目标缺少旧 Course 主表和科目树关系',
  1409. Setting_Update: '目标缺少 Exam_Sys_Answer 作答统计,不能执行 CountDiffcult 难度重算',
  1410. PublishDesign: '旧报刊可视化设计器的前端资产、布局协议与渲染运行时未迁入'
  1411. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧考试动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1412. }
  1413. if (operation === 'legacyHelperBlocker') {
  1414. const action = String(input.action || ''); const blockers = {
  1415. Index: '旧动作会读取服务器 DBConfig.json、等待三秒并执行本地 SQL 脚本建表;云函数环境没有该文件运行时,目标数据类也已由受控迁移预置',
  1416. HelpInfo: '目标 Schema 缺少旧 Notification 帮助信息数据,Angular/云函数也没有旧 SignalR 管理员分组与 HelpInfo 推送协议'
  1417. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧帮助动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1418. }
  1419. if (operation === 'legacyHmsBlocker') {
  1420. const action = String(input.action || ''); const blockers = {
  1421. UList: '目标用户数据没有旧 ZL_UserBase.cardType 与 ICQ 到期时间字段,不能列出主题大师 VIP 卡用户',
  1422. VipAdd: '目标用户数据没有旧 cardType 空值状态,不能判定可开通主题大师 VIP 卡的用户',
  1423. VipAddJump: '旧七档主题大师卡与当前小树 VIP 合伙人等级不是同一业务,不能复用 VIP 字段',
  1424. VipUpdate: '目标未保留旧 cardType 与 ICQ 卡片到期时间,无法还原编辑页',
  1425. VipUpdate_Submit: '目标未保留旧 cardType/ICQ,不能安全写入卡种或按原到期日、当前时间重算期限',
  1426. VipRenewal: '目标没有旧主题大师卡到期时间,无法展示续约上下文',
  1427. VipRenewal_Submit: '目标没有旧 cardType/ICQ,不能按卡种天数或指定日期续约',
  1428. VipOverdue: '目标没有旧主题大师卡到期时间,无法展示强制过期上下文',
  1429. VipOverdue_Submit: '目标没有旧 ICQ 到期时间,不能执行强制过期且不能误写当前 VIP 合伙人字段',
  1430. OList: '目标 Schema 缺少旧 ZL_Orderinfo 与 ZL_CartPro,无法查询主题大师 VIP 卡订单'
  1431. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Hms 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1432. }
  1433. if (operation === 'legacyIndexBlocker') {
  1434. const action = String(input.action || ''); const blockers = {
  1435. Scence: '旧桌面布局与主题写入 SiteConfig.SiteOption.Desk 和 B_Admin.Theme/StructureID;目标没有等价站点配置或管理员主题字段',
  1436. Lockin: '旧锁屏依赖 B_Admin.LockScreen、明文口令再认证和 ASP.NET Session;目标没有持久化锁屏状态,不能把停用账号冒充屏幕锁',
  1437. Lockin_Submit: '旧提交动作为空且锁屏状态依赖 ASP.NET Session/B_Admin,云函数没有可等价提交的数据',
  1438. ASCXLoad: '旧动作按请求名称动态装载服务器 Razor/ASCX 局部视图;Angular 构建产物不允许运行时拼接并执行任意服务端模板路径',
  1439. API: '旧 animate_start/animate_stop 写入全局 SiteConfig.SiteOption.Admin_ShowAnimate;目标没有该站点级持久化配置',
  1440. AccountForm: '旧公开管理员申请受 SiteOption.RegManager 控制并创建默认锁定、高权限角色账号;目标没有审批开关和管理员申请模型,不能开放不安全的自助管理员入口',
  1441. AccountForm_API: '目标没有管理员申请开关与待审批账号模型,不能提供公开管理员用户名/邮箱探测接口',
  1442. AccountForm_Submit: '旧动作生成随机密码并直接授予固定角色 2/4/6/7/8;目标权限模型不等价且没有审批链,不能安全创建管理员申请'
  1443. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧后台入口动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1444. }
  1445. if (operation === 'legacyMarkdownBlocker') {
  1446. const action = String(input.action || ''); const blockers = {
  1447. Index: '目标 Schema 缺少旧 MarkDown 主表、MDName/MDPath/MDStatus/MDPosition 等历史记录', MD: '目标没有可新建或编辑的 MarkDown 记录', Index_submit: '目标缺少 MarkDown 持久层,当前 Parse 文件适配器也不能替代旧 wwwroot/UploadFiles/MarkDown 本地上传目录', Dels: '目标没有 Markdown 记录与对应服务器文件可同步删除', Preview: '目标缺少 MarkDown 文件、SiteInfo.SiteUrl 与旧 /PreView/md 渲染运行时'
  1448. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Markdown 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1449. }
  1450. if (operation === 'legacyMessageBlocker') {
  1451. const action = String(input.action || ''); const blockers = {
  1452. Message: '目标 Schema 缺少旧 Message 站内信主表与收发历史;MailTemp 仅是邮件模板,语义不等价', MessageSend: '目标没有可编辑的站内信草稿、收件人、抄送和附件记录', Message_Add: '目标缺少站内信发送与草稿持久层,不能保存 Sender/Incept/CCUser/Attachment', MessageRead: '目标没有站内信详情与已读状态记录', Message_API: '目标没有可删除的站内信记录'
  1453. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧站内信动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1454. }
  1455. if (operation === 'legacyMobileBlocker') {
  1456. const action = String(input.action || ''); const blockers = {
  1457. Index: '旧动作只有已丢失的 Mobile Razor 视图,发布包没有页面结构、交互或资源基线可恢复', MobileBrower: '旧移动浏览器预览页只有已丢失的 Razor 视图,目标没有对应渲染运行时'
  1458. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Mobile 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1459. }
  1460. if (operation === 'legacyNodeRemainderBlocker') {
  1461. const action = String(input.action || ''); const blockers = {
  1462. ZhiDing: '旧动作会把帐套内全部 ZStatus=99 栏目强制设为 depth=1、parentId=0,破坏现有规范化栏目树;现有 saveNode/nodeBatch 已在每次移动时安全维护深度', UnionNode_Merge: '旧合并同时改写 CommonModel 与未迁入的 ZL_Commodities 商品节点,并可删除来源栏目;缺商品主表时不能执行非原子半合并'
  1463. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧栏目剩余动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1464. }
  1465. if (operation === 'platformCompanies') {
  1466. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); if (search.length > 100) fail(400, '协同办公企业搜索词不能超过 100 个字符'); const fields = await schemaFor('PlatComp'); const query = new Parse.Query('PlatComp'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); const userFields = await schemaFor('_User'); const userQuery = new Parse.Query('_User'); applyTenant(userQuery, userFields, context, input.companyId); userQuery.limit(1000); const users = await userQuery.find({ useMasterKey: true }); const userNames = new Map(users.map((user) => [Number(user.get('legacyUserId') || (user.get('legacyUserData') || {}).UserID || 0), String(user.get('username') || '')])); results = results.filter((entry) => !search || [entry.get('compName'),entry.get('compShort'),entry.get('telephone'),entry.get('mobile'),entry.get('faren'),entry.get('industry')].some((value) => String(value || '').toLowerCase().includes(search))); results.sort((left,right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; const rows = results.slice((page - 1) * pageSize, page * pageSize).map((entry) => ({ ...serializeObject(entry), creatorUsername: userNames.get(Number(entry.get('createUser')) || 0) || '' })); return response.json({ success: true, data: { className: 'PlatComp', page, pageSize, total, results: rows } });
  1467. }
  1468. if (operation === 'savePlatformCompany') {
  1469. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const compName = String(payload.compName || '').trim(); const creatorUsername = String(input.creatorUsername || '').trim(); const rawCreateTime = payload.createTime && typeof payload.createTime === 'object' ? payload.createTime.iso : payload.createTime; const rawRegisterTime = payload.registerTime && typeof payload.registerTime === 'object' ? payload.registerTime.iso : payload.registerTime; const createTime = rawCreateTime ? new Date(String(rawCreateTime)) : new Date(); const registerTime = rawRegisterTime ? new Date(String(rawRegisterTime)) : new Date(createTime.getTime() - 365 * 86400000); const compType = Number(payload.compType || 0); const registerMoney = Number(payload.registerMoney || 0); const strings = { compLogo:String(payload.compLogo || '').trim(), telephone:String(payload.telephone || '').trim(), mobile:String(payload.mobile || '').trim(), compHref:String(payload.compHref || '').trim(), compDesc:String(payload.compDesc || '').trim(), mails:String(payload.mails || '').trim(), compShort:String(payload.compShort || '').trim(), photo:String(payload.photo || '').trim(), faren:String(payload.faren || '').trim(), peoples:String(payload.peoples || '').trim(), keywords:String(payload.keywords || '').trim(), industry:String(payload.industry || '').trim(), registerAddr:String(payload.registerAddr || '').trim(), proAddr:String(payload.proAddr || '').trim() }; if (!compName || compName.length > 200 || Number.isNaN(createTime.getTime()) || Number.isNaN(registerTime.getTime()) || !Number.isInteger(compType) || compType < 0 || compType > 1000000 || !Number.isInteger(registerMoney) || registerMoney < 0 || registerMoney > 1000000000000 || Object.values(strings).some((value) => value.length > 50000) || strings.compLogo.length > 2000 || strings.photo.length > 5000 || strings.compHref.length > 2000 || strings.telephone.length > 100 || strings.mobile.length > 100 || strings.mails.length > 1000 || strings.compShort.length > 200 || strings.faren.length > 100 || strings.peoples.length > 100 || strings.keywords.length > 1000 || strings.industry.length > 200 || strings.registerAddr.length > 2000 || strings.proAddr.length > 2000 || /[<>\r\n]/.test(strings.compHref) || /^(?:javascript|data|vbscript):/i.test(strings.compHref)) fail(400, '企业名称、创建人、日期、网址或资料字段无效'); let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '协同办公企业必须指定帐套'); const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('PlatComp'); let target; let created = false; if (objectId) { const query = new Parse.Query('PlatComp'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; } else { target = new Parse.Object('PlatComp'); target.set('sourceKey', 'cloud:platform-company:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('company', company); target.set('status', 0); created = true; } let createUser = Number(target.get('createUser')) || Number(context.current.get('legacyUserId')) || Number((context.current.get('legacyUserData') || {}).UserID) || 0; if (creatorUsername) { const row = 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 LOWER("username")=LOWER($2) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1', [companyId, creatorUsername]); createUser = Number(row && row.id) || 0; } if (!createUser) fail(400, '企业创建账号不存在、缺少旧用户 ID 或不属于当前帐套'); const creator = await Psql.oneOrNone('SELECT "objectId" FROM "_User" WHERE "company"=$1 AND COALESCE("legacyUserId",CASE WHEN COALESCE("legacyUserData"->>\'UserID\',\'\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'UserID\')::numeric END)=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1', [companyId, createUser]); if (!creator) fail(404, '企业创建人不存在或不属于当前帐套'); const duplicateQuery = new Parse.Query('PlatComp'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.limit(1000); const duplicates = await duplicateQuery.find({ useMasterKey: true }); if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('compName') || '').trim().toLowerCase() === compName.toLowerCase())) fail(409, '同一帐套的协同办公企业名称不能重复'); target.set('compName', compName); target.set('createTime', createTime); target.set('registerTime', registerTime); target.set('createUser', createUser); target.set('compType', compType); target.set('registerMoney', registerMoney); for (const [name,value] of Object.entries(strings)) target.set(name, value); try { await target.save(null, { useMasterKey: true }); if (created) { const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-platform-company-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "PlatComp",lock_row WHERE "company"=$1) UPDATE "PlatComp" SET "id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]); if (!Number(rows[0] && rows[0].id)) throw new Error('无法分配协同办公企业编号'); } } catch (error) { if (created && target.id) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '协同办公企业保存失败: ' + String(error && error.message || error)); } await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || company }, objectId ? 'update-platform-company' : 'create-platform-company', 'PlatComp', target.id); return response.json({ success: true, data: serializeObject(target) });
  1470. }
  1471. if (operation === 'platformCompanyMembers') {
  1472. const companyObjectId = String(input.companyObjectId || input.objectId || ''); const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 100)); const search = String(input.search || '').trim().toLowerCase(); if (!companyObjectId || search.length > 100) fail(400, '缺少企业或成员搜索词过长'); const fields = await schemaFor('PlatComp'); const companyQuery = new Parse.Query('PlatComp'); applyTenant(companyQuery, fields, context, input.companyId); const platformCompany = await companyQuery.get(companyObjectId, { useMasterKey: true }); const compId = Number(platformCompany.get('id')) || 0; if (!compId) fail(409, '企业缺少旧数字 ID'); const userFields = await schemaFor('_User'); const userQuery = new Parse.Query('_User'); applyTenant(userQuery, userFields, context, input.companyId); userQuery.limit(1000); let users = await userQuery.find({ useMasterKey: true }); const creatorUsername = String((users.find((user) => Number(user.get('legacyUserId') || (user.get('legacyUserData') || {}).UserID || 0) === Number(platformCompany.get('createUser') || 0)) || {}).get?.('username') || ''); users = users.filter((user) => { const platform = user.get('legacyUserPlat') || {}; return Number(platform.CompID || 0) === compId && (!search || [user.get('username'),user.get('realName'),user.get('nickname'),(user.get('legacyUserData') || {}).HoneyName,platform.TrueName,platform.Mobile,platform.Post].some((value) => String(value || '').toLowerCase().includes(search))); }); users.sort((left,right) => Number((left.get('legacyUserPlat') || {}).UserID || left.get('legacyUserId') || 0) - Number((right.get('legacyUserPlat') || {}).UserID || right.get('legacyUserId') || 0)); const total = users.length; const results = users.slice((page - 1) * pageSize, page * pageSize).map((user) => { const platform = user.get('legacyUserPlat') || {}; const legacy = user.get('legacyUserData') || {}; return { objectId:user.id, userId:Number(platform.UserID || user.get('legacyUserId') || legacy.UserID || 0), username:String(user.get('username') || ''), displayName:String(platform.TrueName || user.get('realName') || user.get('nickname') || legacy.HoneyName || user.get('username') || ''), mobile:String(platform.Mobile || user.get('mobile') || ''), userFace:String(platform.UserFace || user.get('avatar') || ''), post:String(platform.Post || ''), status:Number(platform.Status || 0), createTime:String(platform.CreateTime || '') }; }); return response.json({ success:true, data:{ companyObjectId, compId, creatorUsername, page, pageSize, total, results } });
  1473. }
  1474. if (operation === 'platformCompanyMemberBatch') {
  1475. const companyObjectId = String(input.companyObjectId || ''); const action = String(input.action || ''); if (!companyObjectId || !['add','remove'].includes(action)) fail(400, '不支持的企业成员操作'); const fields = await schemaFor('PlatComp'); const companyQuery = new Parse.Query('PlatComp'); applyTenant(companyQuery, fields, context, input.companyId); const platformCompany = await companyQuery.get(companyObjectId, { useMasterKey: true }); const compId = Number(platformCompany.get('id')) || 0; const companyId = pointerId(platformCompany.get('company')) || pointerId(context.company); if (!compId || !companyId) fail(409, '企业缺少旧编号或帐套'); const userFields = await schemaFor('_User'); let targets = []; if (action === 'add') { const usernames = [...new Set((Array.isArray(input.usernames) ? input.usernames : [input.username]).map((value) => String(value || '').trim()).filter(Boolean))]; if (!usernames.length || usernames.length > 100 || usernames.some((value) => !/^[A-Za-z0-9_@.\-\u4e00-\u9fff]{2,64}$/.test(value))) fail(400, '每次请输入 1 至 100 个有效成员账号'); const rows = await Psql.query('SELECT "objectId" FROM "_User" WHERE "company"=$1 AND LOWER("username")=ANY($2::text[]) AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)', [companyId, usernames.map((value) => value.toLowerCase())]); if (rows.length !== usernames.length) fail(404, '部分成员账号不存在或不属于当前帐套'); const query = new Parse.Query('_User'); applyTenant(query, userFields, context, companyId); query.containedIn('objectId', rows.map((row) => row.objectId)); query.limit(rows.length); targets = await query.find({ useMasterKey:true }); for (const target of targets) { const legacy = target.get('legacyUserData') || {}; const userId = Number(target.get('legacyUserId') || legacy.UserID || 0); if (!userId) fail(409, '成员账号缺少旧用户 ID'); const existing = target.get('legacyUserPlat'); const platform = existing && typeof existing === 'object' && !Array.isArray(existing) ? { ...existing } : { Post:'', Mobile:String(target.get('mobile') || ''), Status:1, UserID:userId, ATCount:null, TrueName:String(target.get('realName') || target.get('nickname') || legacy.HoneyName || target.get('username') || ''), UserFace:String(target.get('avatar') || ''), Plat_Role:'', CreateTime:new Date().toISOString(), Plat_Group:null }; platform.UserID = userId; platform.CompID = compId; if (!platform.CreateTime) platform.CreateTime = new Date().toISOString(); target.set('legacyUserPlat', platform); } await Parse.Object.saveAll(targets, { useMasterKey:true }); } else { const objectIds = [...new Set((Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]).map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个企业成员'); const query = new Parse.Query('_User'); applyTenant(query, userFields, context, companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); targets = await query.find({ useMasterKey:true }); if (targets.length !== objectIds.length || targets.some((target) => Number((target.get('legacyUserPlat') || {}).CompID || 0) !== compId)) fail(404, '部分成员不存在或不属于该企业'); for (const target of targets) target.unset('legacyUserPlat'); await Parse.Object.saveAll(targets, { useMasterKey:true }); } for (const target of targets) await audit({ ...context, company: platformCompany.get('company') || context.company }, action + '-platform-company-member', '_User', target.id); return response.json({ success:true, data:{ action, updated:targets.length } });
  1476. }
  1477. if (operation === 'platformCompanyBatch') {
  1478. if (String(input.action || '') !== 'delete') fail(400, '不支持的协同办公企业批量操作'); const objectIds = [...new Set((Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]).map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个协同办公企业'); const fields = await schemaFor('PlatComp'); const query = new Parse.Query('PlatComp'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey:true }); if (targets.length !== objectIds.length) fail(404, '部分协同办公企业不存在或不属于当前帐套'); for (const target of targets) { const companyId = pointerId(target.get('company')); const compId = Number(target.get('id')) || 0; const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "_User" WHERE "company"=$1 AND COALESCE("legacyUserPlat"->>\'CompID\',\'\') ~ \'^[0-9]+$\' AND ("legacyUserPlat"->>\'CompID\')::numeric=$2) AS members,(SELECT COUNT(*)::int FROM "PlatUserRole" WHERE "company"=$1 AND COALESCE("compId",0)=$2) AS roles', [companyId, compId]); if (Number(refs.members) || Number(refs.roles)) fail(409, '企业仍有协同办公成员或角色,不能删除'); } await Parse.Object.destroyAll(targets, { useMasterKey:true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-platform-company', 'PlatComp', target.id); return response.json({ success:true, data:{ action:'delete', updated:targets.length } });
  1479. }
  1480. if (operation === 'legacyPlatBlocker') {
  1481. const action = String(input.action || ''); const blockers = { Default:'旧默认页跳转到已缺失 Blog_Msg 动态管理,目标没有可进入的信息首页', AuditApply:'目标 Schema 缺少旧 Common_UserApply 协同办公申请主表', AuditApply_Agree:'目标没有可批量通过并改为状态 99 的协同办公申请', AuditApply_Reject:'目标没有可批量拒绝并改为状态 -1 的协同办公申请', CreateComp:'旧动作仅剩已丢失的 Razor 企业创建向导;Angular 企业表单已覆盖主表生命周期,但未知向导交互无法 1:1 恢复', PlatInfoDeail:'目标 Schema 缺少旧 Blog_Msg 动态消息主表', PlatInfoDetail_Submit:'目标没有可编辑正文与日期的旧协同办公动态消息', PlatInfoManage:'目标没有 Blog_Msg 顶级动态、回收状态和关键词数据', TopicList:'目标 Schema 缺少旧 Plat_Topic 话题主表与星标状态', WordTlp:'旧文字模板仅剩已丢失的 Razor 视图,目标没有模板数据或渲染协议', Crud:'目标 Schema 缺少旧 Plat_Group 企业部门/群组树', CrudAdd:'目标没有可编辑的企业群组、上级和成员管理员关系', CrudAdd_Submit:'目标缺少 Plat_Group 的 FirstID/Depth/ManageIDS/MemberIDS 持久层', GroupAdmin_API:'目标没有可删除的 Plat_Group 企业群组记录', API_msg_topic:'目标缺少 Blog_Msg 与 Plat_Topic,不能执行动态删除/恢复/清空或话题删除/星标' }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧协同办公动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1482. }
  1483. if (operation === 'legacyPrinterBlocker') {
  1484. const action = String(input.action || '');
  1485. if (action !== 'Index') fail(400, '不支持的旧打印动作');
  1486. fail(501, 'migration_blocked: 旧 PrinterController.Index 只返回已丢失的 Razor 视图;目标 110 个 Schema 中没有旧 ZL_Shop_PrintDevice、ZL_Shop_PrintTlp、ZL_Shop_PrintMessage 数据,旧客户端还依赖 my.feyin.net 的明文 HTTP 设备协议,无法安全等价恢复');
  1487. }
  1488. if (operation === 'pageStyles') {
  1489. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim(); if (search.length > 100) fail(400, '黄页样式搜索词不能超过 100 个字符'); const fields = await schemaFor('PageStyle'); const query = new Parse.Query('PageStyle'); applyTenant(query, fields, context, input.companyId); if (search) query.matches('pageNodeName', escapeRegex(search), 'i'); query.ascending('orderid'); const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize); const results = await query.find({ useMasterKey: true }); return response.json({ success: true, data: { className: 'PageStyle', page, pageSize, total, results: results.map(serializeObject) } });
  1490. }
  1491. if (operation === 'savePageStyle') {
  1492. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const pageNodeName = String(payload.pageNodeName || '').trim(); const stylePath = String(payload.stylePath || '').trim(); const templateIndex = String(payload.templateIndex || '').trim(); const templateIndexPic = String(payload.templateIndexPic || '').trim(); const orderid = Number(payload.orderid || 0); const isDefault = Number(payload.isDefault || 0); const istrue = Number(payload.istrue == null ? 1 : payload.istrue); if (!pageNodeName || pageNodeName.length > 100 || [stylePath,templateIndex,templateIndexPic].some((value) => value.length > 2000 || /[<>\r\n]/.test(value)) || !Number.isInteger(orderid) || orderid < 0 || orderid > 1000000 || ![0,1].includes(isDefault) || ![0,1].includes(istrue)) fail(400, '黄页样式名称、路径、排序或状态无效'); let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '黄页样式必须指定帐套'); const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('PageStyle'); let target; let created = false; if (objectId) { const query = new Parse.Query('PageStyle'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; } else { target = new Parse.Object('PageStyle'); target.set('sourceKey', 'cloud:page-style:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('company', company); target.set('addtime', new Date()); created = true; } const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "PageStyle" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("pageNodeName",\'\')))=LOWER($3) LIMIT 1', [companyId, objectId, pageNodeName]); if (duplicate) fail(409, '同一帐套的黄页样式名称不能重复'); target.set('pageNodeName', pageNodeName); target.set('stylePath', stylePath); target.set('templateIndex', templateIndex); target.set('templateIndexPic', templateIndexPic); target.set('orderid', orderid); if (isDefault === 0) target.set('isDefault', 0); target.set('istrue', istrue); try { await target.save(null, { useMasterKey: true }); if (created) { const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-page-style-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("pageNodeid"),0)+1 AS id FROM "PageStyle",lock_row WHERE "company"=$1) UPDATE "PageStyle" SET "pageNodeid"=next_id.id,"sourceKey"=\'[["PageNodeid",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]); if (!Number(rows[0] && rows[0].id)) throw new Error('无法分配黄页样式编号'); } if (isDefault === 1) { const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-page-style-default:\'||$1))) UPDATE "PageStyle" SET "isDefault"=CASE WHEN "objectId"=$2 THEN 1 ELSE 0 END,"updatedAt"=NOW() FROM lock_row WHERE "company"=$1 AND ("objectId"=$2 OR COALESCE("isDefault",0)=1) RETURNING "objectId"', [companyId, target.id]); if (!rows.some((row) => row.objectId === target.id)) throw new Error('无法设置默认黄页样式'); } } catch (error) { if (created && target.id) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '黄页样式保存失败: ' + String(error && error.message || error)); } await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || company }, objectId ? 'update-page-style' : 'create-page-style', 'PageStyle', target.id); return response.json({ success: true, data: serializeObject(target) });
  1493. }
  1494. if (operation === 'pageStyleBatch') {
  1495. if (String(input.action || '') !== 'delete') fail(400, '不支持的黄页样式批量操作'); const objectIds = [...new Set((Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]).map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个黄页样式'); const fields = await schemaFor('PageStyle'); const query = new Parse.Query('PageStyle'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分黄页样式不存在或不属于当前帐套'); for (const target of targets) { const refs = await Psql.one('SELECT COUNT(*)::int AS count FROM "PageTemplate" WHERE "company"=$1 AND LOWER(TRIM(COALESCE("userGroup",\'\')))=LOWER($2)', [pointerId(target.get('company')), String(target.get('pageNodeName') || '')]); if (Number(refs.count)) fail(409, '黄页样式仍被页面模板引用,不能删除'); } await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-page-style', 'PageStyle', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length } });
  1496. }
  1497. if (operation === 'legacyPageBlocker') {
  1498. const action = String(input.action || ''); const blockers = {
  1499. ApplyAudit: '目标 Schema 缺少旧 PageReg 用户黄页申请主表与审核状态', ApplyInfo: '目标缺少 PageReg 申请详情、用户动态字段和样式关联', PageContent: 'CommonModel 可管理普通内容,但目标缺少 PageReg 与黄页专属模型范围,不能还原旧 Page_Sel', EditContent: '现有内容详情可查看;目标缺少黄页申请与 PageTemplate 上下文,不能还原专用编辑器', Content_Edit: '现有内容可安全编辑主字段;黄页模型附表和 PageReg 关系缺失,不能执行旧动态字段更新', PageConfig: '目标没有 SiteOption.RegPageStart/Page_ContentStatus/Page_UserCanNode 配置层', PageTemplate: 'PageTemplate 为空且目标缺少 PageReg 用户归属,无法还原用户/公共模板树', PageTemplateAdd: '目标缺少 PageReg,不能确定模板的 RegID、用户和样式上下文', SetPageOrder: '目标没有 PageReg 归属的模板集合可安全排序', PageConfig_Update: '目标云函数架构没有旧黄页站点配置持久层', PageTemplate_Add: '目标缺少 PageReg 用户归属和旧模型复选关系,不能安全新增页面模板', SetPageOrder_Batch: '目标没有可按 PageReg 隔离的模板排序集合', SetPageOrder_UpMove: '目标没有可按 PageReg 隔离的相邻模板顺序', SetPageOrder_DownMove: '目标没有可按 PageReg 隔离的相邻模板顺序', Apply_Update: '目标缺少 PageReg 申请主表、黄页模型附表和用户申请字段'
  1500. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧黄页动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1501. }
  1502. if (operation === 'misTypes') {
  1503. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim(); if (search.length > 100) fail(400, '流程类型搜索词不能超过 100 个字符'); const fields = await schemaFor('MisType'); const query = new Parse.Query('MisType'); applyTenant(query, fields, context, input.companyId); if (search) query.matches('typeName', escapeRegex(search), 'i'); query.ascending('id'); const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize); const results = await query.find({ useMasterKey: true }); return response.json({ success: true, data: { className: 'MisType', page, pageSize, total, results: results.map(serializeObject) } });
  1504. }
  1505. if (operation === 'saveMisType') {
  1506. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const typeName = String(payload.typeName || '').trim(); const typeDescribe = String(payload.typeDescribe || '').trim(); if (!typeName || typeName.length > 100 || typeDescribe.length > 1000) fail(400, '流程类型名称为必填项且名称或说明过长'); let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '流程类型必须指定帐套'); const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('MisType'); let target; let created = false; if (objectId) { const query = new Parse.Query('MisType'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; } else { target = new Parse.Object('MisType'); target.set('sourceKey', 'cloud:mis-type:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('company', company); target.set('createTime', new Date()); created = true; }
  1507. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "MisType" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("typeName",\'\')))=LOWER($3) LIMIT 1', [companyId, objectId, typeName]); if (duplicate) fail(409, '同一帐套的流程类型名称不能重复'); target.set('typeName', typeName); target.set('typeDescribe', typeDescribe); try { await target.save(null, { useMasterKey: true }); if (created) { const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-mis-type-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "MisType",lock_row WHERE "company"=$1) UPDATE "MisType" SET "id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]); if (!Number(rows[0] && rows[0].id)) throw new Error('无法分配流程类型编号'); } } catch (error) { if (created && target.id) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '流程类型保存失败: ' + String(error && error.message || error)); } await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || company }, objectId ? 'update-mis-type' : 'create-mis-type', 'MisType', target.id); return response.json({ success: true, data: serializeObject(target) });
  1508. }
  1509. if (operation === 'misTypeBatch') {
  1510. if (String(input.action || '') !== 'delete') fail(400, '不支持的流程类型批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个流程类型'); const fields = await schemaFor('MisType'); const query = new Parse.Query('MisType'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分流程类型不存在或不属于当前帐套'); for (const target of targets) { const refs = await Psql.one('SELECT COUNT(*)::int AS count FROM "MisProcedure" WHERE "company"=$1 AND COALESCE("typeId",0)=$2', [pointerId(target.get('company')), Number(target.get('id')) || 0]); if (Number(refs.count)) fail(409, '流程类型仍被 OA 流程引用,不能删除'); } await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-mis-type', 'MisType', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length } });
  1511. }
  1512. if (operation === 'legacyOABlocker') {
  1513. const action = String(input.action || ''); const blockers = {
  1514. Default: '目标缺少旧 OA_AuditFlow 审核流主表与历史数据', FlowInfo: '目标没有旧审核流名称、模型、模板、角色用户和会签配置记录', FlowInfo_Submit: '目标缺少 OA_AuditFlow 持久层,MisProcedure 是不同版本流程结构', Flow_API: '目标没有可删除的旧审核流记录', SelFlowModel: '目标缺少旧审核流可绑定的 Mis_Model 与套红模板关系', FlowItem: '目标缺少 OA_AuditFlowItem 审核步骤表', FlowItemInfo: '目标没有审核步骤、绑定用户、父步骤、会签、转交和退回记录', FlowItemInfo_Submit: '目标缺少审核步骤持久层', FlowItem_API: '目标没有可删除的审核步骤记录',
  1515. AddMisModel: '目标 DocModel 与旧 Mis_Model 的 ModelContent/BindNode/DocType/WordPath 套红结构不等价', AddMisModel_Submit: '目标缺少旧 Mis_Model 持久层和 FileGuid 套红模板文件运行时', AddSign: '目标 MisSign 是考勤时间表,不是 OA_Sign 签章模型', AddSign_Submit: '目标缺少 OA_Sign 图片、名称、口令、密钥、所属用户与创建人持久层,不能保存明文签章口令', AddSign_Delete: '目标没有可删除的 OA_Sign 签章记录', ApplyManage: '目标缺少 OA_Document 文档申请主表', ApplyManage_Delete: '目标没有可删除的 OA 文档申请记录', MisModelManage: '目标缺少旧 Mis_Model 套红文档模型数据', MisModelManage_Delete: '目标没有可删除的旧套红文档模型', OAConfig: '旧动作只有已丢失的 OAConfig Razor 视图,目标没有对应站点配置模型', SelModelFieds: '目标 Model 数据可只读查看,但旧按 ModelType 选择并回填字段的 OA Razor 交互未迁入', SignManage: '目标缺少 OA_Sign 签章列表;MisSign 是不等价的考勤时间表'
  1516. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 OA 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1517. }
  1518. if (operation === 'legacyDesignInteractiveBlocker') {
  1519. const action = String(input.action || ''); const blockers = {
  1520. InteractiveList: '目标 Schema 缺少旧 Design_Pub 交互提交表及 sceneid、uid、fname 数据;DesignAnswer 和 Pub 语义不等价',
  1521. Interactive_API: '目标没有可按旧 Design_Pub ID 删除的交互提交记录'
  1522. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧互动提交动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1523. }
  1524. if (operation === 'legacyIServerBlocker') {
  1525. const action = String(input.action || ''); const blockers = {
  1526. Default: '目标 Schema 缺少旧 IServer 服务工单主表与回复表', AddQuestionRecord: '目标无可新增的 IServer 工单、CRM 关联和附件模型', AddQuestionRecord_Submit: '目标缺少工单标题、内容、优先级、类型、来源、状态、附件及 CRM 关系持久层',
  1527. BiServer: '目标没有可按状态、类型和标题筛选的 IServer 工单数据', BiServer_Del: '目标没有可删除的 IServer 工单记录', BiServerInfo: '目标缺少工单详情、阅读计数及回复关系', DelIServer: '目标缺少 IServerReply 回复记录', UpdateIServer: '目标缺少工单状态、优先级、来源、类型、备注和解决时间持久层', BselectiServer: '目标没有可供 CRM 选择的旧服务工单数据', ISReplyAdd: '目标缺少工单回复主表与父工单关系', ISReplyAdd_Submit: '目标缺少管理员工单回复、附件和内容持久层'
  1528. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 iServer 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1529. }
  1530. if (operation === 'legacyLicenceBlocker') {
  1531. const action = String(input.action || ''); const blockers = {
  1532. LicenceFiles: '目标 Schema 缺少 LicenceFiles 协议文件定义与限定用户数据', LicenceFiles_API: '目标没有可删除的协议文件定义', LicenceFilesAdd: '目标无协议模板路径、字段、状态、限制类型和限定用户模型', LicenceFilesAdd_Submit: '目标缺少协议文件定义持久层且云函数不能写旧本地模板文件',
  1533. LicenceSignApplyList: '目标缺少 Common_UserApply 的 user_signlicence 申请数据', LicenceSignApplyDetail: '目标缺少许可签署申请、用户和协议文件关联', LicenceSignApply_API: '目标缺少申请审核、签署记录与服务器 PDF 生成/本地文件哈希归档运行时', LicenceSignList: '目标 Schema 缺少 LicenceUserSign 签约记录', LicenceSignDetail: '目标缺少签约详情、协议文件和用户关联', LicenceSign_API: '目标没有可审核或驳回的签约记录',
  1534. LicenceList: '目标 Schema 缺少 ZL_LicenceFiles 用户协议定义、强制签署类型与限定用户/用户组数据', LicenceAdd: '目标没有可编辑或新增的用户协议、PDF 路径、字段映射和适用范围记录', LicenceAdd_Submit: '目标缺少 LicenceFiles 持久层及协议 PDF 文件,不能只保存失效路径', Licence_API: '目标没有可启用、停用或删除的用户协议定义', LicenceUserSign: '目标 Schema 缺少 ZL_LicenceUserSign 用户签约历史及协议、用户关联'
  1535. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧许可签约动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1536. }
  1537. if (operation === 'legacyUserPromoBlocker') {
  1538. const action = String(input.action || ''); const blockers = {
  1539. Index: '旧入口只重定向到提现申请页,目标缺少 Cash 提现主表,无法提供等价落点', WD: '目标 Schema 缺少 Cash 提现申请、手续费、结果、凭证与处理状态记录', WD_API: '目标没有可审核的提现申请记录', WDAudit: '目标缺少指定提现申请的金额、手续费、账户和处理详情', WDAudit_Accept: '目标缺少可原子审核的 Cash 提现事务记录', WDAudit_Reject: '目标缺少 Cash 提现申请及对应预扣余额账本,不能安全执行拒绝退款', UserBank: '目标 Schema 缺少 User_Bank 提现账户及审核状态数据', UserBankAdd: '目标没有可编辑的开户人、卡号、账户类型和开户行记录', UserBankAdd_Submit: '目标缺少 User_Bank 持久层,不能把提现账户错写到发票银行字段', UserBank_API: '目标没有可删除、审核或取消审核的提现账户记录'
  1540. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧推广提现动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1541. }
  1542. if (operation === 'legacyPubBlocker') {
  1543. const action = String(input.action || ''); const blockers = {
  1544. PubManage: '目标 Pub 模块定义与 PubTw/PubWTHD/PubZXDC 提交类均为空,无法还原互动模块列表', Pub_API: '旧删除、复制、恢复依赖 Pub 定义与动态表,modelname 还会检查 SQL 物理表;目标没有可操作记录或动态建表运行时', Pubinfo: '旧动作只跳转到指定 Pub 定义编辑页,目标 Pub 没有任何历史模块', PubAdd: '新增互动模块需要动态模型、表、标签与模板选择,目标云函数没有等价创建运行时', PubAdd_Submit: '旧提交会动态建 SQL 表、Model/ModelField、XML 标签及两套服务器 HTML 模板,云函数不能等价执行', Pubsinfo: '目标没有 Pub 定义可确定提交表,三个已签约 Pub 提交类也全部为空', PubsinfoAdd: '目标缺少互动模块定义、动态字段与可编辑的提交记录', PubsinfoAdd_Submit: '目标无法根据空的 Pub 定义选择动态提交表并保存系统字段与模型字段', PubInfo_API: '目标没有可删除、审核或取消审核的互动提交记录', PubInfo2_API: '旧企业入驻分支硬编码依赖缺失的 ZL_Pub_BDQYSH 提交表,不能直接改写企业与用户归属', PubsinfoReply: '目标没有互动父记录、动态字段和回复上下文', PubsinfoReply_Submit: '目标缺少可确定回复表的 Pub 定义及父提交记录'
  1545. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧互动模块动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1546. }
  1547. if (operation === 'legacyPushBlocker') {
  1548. const action = String(input.action || ''); const blockers = {
  1549. Index: '旧入口只跳转到推送 API 列表,目标缺少 Mobile_PushAPI 配置数据', APIList: '目标 Schema 缺少 Mobile_PushAPI 的别名、平台、AppKey 与密钥记录', API_del: '目标没有可定位并删除的推送服务配置', AddAPI: '目标没有可编辑或新增的极光推送 API 配置模型', AddAPI_Submit: '签约数据库未提供可安全存储和轮换推送 AppKey/AppSecret 的配置类', PushMsg: '目标缺少可选择的推送 API 配置与设备受众数据', PushMsg_Submit: '目标没有 JPush 凭据、SDK 消费者、ZL_UserBase.UC 设备注册号或移动端注册链,无法真实发送并记录结果', PushTlp: '目标 Schema 缺少 Mobile_PushTlp 推送模板与状态数据', AddPushTlp: '目标没有可编辑或新增的推送模板记录', AddPushTlp_Submit: '目标缺少推送模板名称、标题、内容和参数持久层', PushTlp_API: '目标没有可启用、停用或删除的推送模板', MsgList: '目标 Schema 缺少 Mobile_PushMsg 发送历史、受众与结果记录'
  1550. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧推送动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1551. }
  1552. if (operation === 'legacyPWABlocker') {
  1553. const action = String(input.action || ''); const blockers = {
  1554. Default: '旧入口跳转到离线缓存配置;目标没有可动态改写已部署前端的 PWA 控制面', Default_Submit: '旧提交为空操作,目标不伪造无效果的保存成功', Mobile: '目标缺少旧 SiteOption PWA 配置、manifest 文件和多尺寸图标资产', Mobile_Submit: '云函数不能缩放上传图标、写 manifest.json、注入站点 head 或持久化旧 SiteConfig', Offline: '目标没有旧 PWA_PC_Enable/Exts/IndexUrl/PreCache 配置与服务器模板', Offline_Submit: '云函数不能读取 pwa_sw_js.tlp 并覆盖已部署站点根目录 /sw.js'
  1555. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 PWA 动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1556. }
  1557. if (operation === 'legacyQuartzTaskBlocker') {
  1558. const action = String(input.action || ''); const blockers = {
  1559. TaskList: '目标 Schema 缺少 QuartzNetTask 任务定义、类型、状态、次数和历史记录;空 SenTask 语义不等价', ViewLog: '目标没有 QuartzNetTask.Log 执行日志可按任务查看', TaskAdd: '目标没有 Quartz IScheduler、ScheduleTaskHandler 与任务持久层,无法创建一次或循环任务', TaskEdit: '目标没有可编辑的 Quartz 任务定义', TaskUpdate: '目标没有可同步更新持久层与 Quartz 调度器的控制面', ExecuteNow: '云函数环境没有已注册的 Quartz 作业可立即触发', TaskDelete: '目标没有可同时删除 Quartz 作业与任务记录的调度控制面', TaskDeleteRange: '目标没有可批量注销 Quartz 作业与删除任务记录的控制面', TaskPause: '云函数环境没有常驻 Quartz 调度器或可暂停作业', TaskResume: '云函数环境没有常驻 Quartz 调度器或可恢复作业'
  1560. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧 Quartz 任务动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1561. }
  1562. if (operation === 'legacyLogBlocker') {
  1563. const action = String(input.action || ''); const blockers = {
  1564. TxtLog: '云函数运行时没有旧 /Log/{LogType}/ 本地日志目录', TxtLogContent: '云函数不能按文件名读取旧服务器文本日志', TxtLog_Down: '云函数没有旧服务器日志文件可下载',
  1565. TaskList: '现有 SenTask 是空的发送任务类,不含旧 Content_ScheTask 的 TaskName/TaskContent/ExecuteType/Interval/TaskFlag 数据', TaskAdd: '目标缺少旧调度任务模型与 Hangfire 控制面', TaskAdd_API: '目标不能复制旧任务并向 Hangfire 注册 RecurringJob', TaskAdd_Submit: '目标缺少旧任务持久层、本地脚本校验与 Hangfire 调度运行时', Task_API: '目标不能对不存在的 Hangfire 任务执行删除、停用、启用或立即执行', TaskCenter: '云函数环境没有旧 Hangfire Dashboard 管理页面'
  1566. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧日志任务动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1567. }
  1568. if (operation === 'systemLogs') {
  1569. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim(); const type1 = String(input.type1 || '').trim(); const type2 = String(input.type2 || '').trim(); const startDate = String(input.startDate || '').trim(); const endDate = String(input.endDate || '').trim();
  1570. if (search.length > 100 || type1.length > 100 || type2.length > 100) fail(400, '日志关键词或类型不能超过 100 个字符'); if ((startDate && !/^\d{4}-\d{2}-\d{2}$/.test(startDate)) || (endDate && !/^\d{4}-\d{2}-\d{2}$/.test(endDate))) fail(400, '日志日期格式必须为 YYYY-MM-DD'); const start = startDate ? new Date(startDate + 'T00:00:00+08:00') : null; const end = endDate ? new Date(new Date(endDate + 'T00:00:00+08:00').getTime() + 86400000) : null; if ((start && Number.isNaN(start.getTime())) || (end && Number.isNaN(end.getTime())) || (start && end && start >= end)) fail(400, '日志日期范围无效'); const fields = await schemaFor('SysLog');
  1571. const scopedQuery = () => { const query = new Parse.Query('SysLog'); applyTenant(query, fields, context, input.companyId); if (type1) query.equalTo('type1', type1); if (type2) query.equalTo('type2', type2); if (start) query.greaterThanOrEqualTo('cdate', start); if (end) query.lessThan('cdate', end); return query; };
  1572. let query = scopedQuery(); if (search) { const pattern = escapeRegex(search); query = Parse.Query.or(...['cname','detail','content1','content2','rawUrl'].map((field) => { const candidate = scopedQuery(); candidate.matches(field, pattern, 'i'); return candidate; })); } const total = await query.count({ useMasterKey: true }); query.descending('cdate'); query.skip((page - 1) * pageSize); query.limit(pageSize); const results = await query.find({ useMasterKey: true }); return response.json({ success: true, data: { className: 'SysLog', page, pageSize, total, results: results.map(serializeObject) } });
  1573. }
  1574. if (operation === 'shopFareTemplates') {
  1575. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim(); if (search.length > 100) fail(400, '运费模板搜索词不能超过 100 个字符'); const fields = await schemaFor('ShopFareTlp'); const query = new Parse.Query('ShopFareTlp'); applyTenant(query, fields, context, input.companyId); if (search) query.matches('tlpName', escapeRegex(search), 'i'); query.ascending('id'); const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize); const results = await query.find({ useMasterKey: true }); return response.json({ success: true, data: { className: 'ShopFareTlp', page, pageSize, total, results: results.map(serializeObject) } });
  1576. }
  1577. if (operation === 'saveShopFareTemplate') {
  1578. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const name = String(payload.tlpName || '').trim(); const priceMode = Number(payload.priceMode == null ? 0 : payload.priceMode); const remind = String(payload.remind || '').trim(); const remind2 = String(payload.remind2 || '').trim(); if (!name || name.length > 100 || !Number.isInteger(priceMode) || priceMode < 0 || priceMode > 10 || remind.length > 1000 || remind2.length > 1000) fail(400, '运费模板名称、计价方式或备注无效'); let rules; try { rules = typeof payload.express === 'string' ? JSON.parse(payload.express || '[]') : payload.express; } catch { fail(400, '运费规则必须是 JSON 数组'); } if (!Array.isArray(rules) || rules.length > 50 || rules.some((rule) => !rule || typeof rule !== 'object' || Array.isArray(rule) || String(rule.name || '').length > 100 || Object.values(rule).some((value) => typeof value === 'string' && value.length > 500))) fail(400, '运费规则格式无效或数量超过 50 条'); let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '运费模板必须指定帐套'); const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('ShopFareTlp'); let target; let created = false; if (objectId) { const query = new Parse.Query('ShopFareTlp'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; } else { target = new Parse.Object('ShopFareTlp'); target.set('sourceKey', 'cloud:shop-fare-template:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('company', company); target.set('cdate', new Date()); target.set('userId', 0); target.set('adminId', Number(context.current.get('legacyUserId')) || 0); target.set('isFree', 0); target.set('regionBan', 0); created = true; }
  1579. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "ShopFareTlp" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("tlpName",\'\')))=LOWER($3) LIMIT 1', [companyId, objectId, name]); if (duplicate) fail(409, '同一帐套的运费模板名称不能重复'); target.set('tlpName', name); target.set('priceMode', priceMode); target.set('express', JSON.stringify(rules)); target.set('remind', remind); target.set('remind2', remind2); try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '运费模板初始写入失败: ' + String(error && error.message || error)); }
  1580. try { if (created) { const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-shop-fare-template-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "ShopFareTlp",lock_row WHERE "company"=$1) UPDATE "ShopFareTlp" SET "id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]); if (!(Number(rows[0] && rows[0].id) || 0)) throw new Error('无法分配运费模板编号'); } } catch (error) { if (created) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '运费模板编号写入失败: ' + String(error && error.message || error)); }
  1581. await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || company }, objectId ? 'update-shop-fare-template' : 'create-shop-fare-template', 'ShopFareTlp', target.id); return response.json({ success: true, data: serializeObject(target) });
  1582. }
  1583. if (operation === 'shopFareTemplateBatch') {
  1584. if (String(input.action || '') !== 'delete') fail(400, '不支持的运费模板批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个运费模板'); const fields = await schemaFor('ShopFareTlp'); const query = new Parse.Query('ShopFareTlp'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分运费模板不存在或不属于当前帐套'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-shop-fare-template', 'ShopFareTlp', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length } });
  1585. }
  1586. if (operation === 'legacyShopExpBlocker') {
  1587. const action = String(input.action || ''); const blockers = {
  1588. OrderSend: '目标 Schema 缺少旧 OrderList 订单主表与 Order_Exp 物流单', OrderSend_Submit: '目标缺少订单物流状态、物流单、用户收件手机、SMSConfig 发货模板和 SafeMobile 日志链',
  1589. Factory: '目标 Schema 缺少旧 Manufacturers 厂商主表与历史厂商数据', FactoryAdd: '目标无可编辑或新增的 Manufacturers 厂商记录', FactoryAdd_Submit: '目标缺少厂商名称、简称、地址、电话、传真、网站、分类、图片和介绍持久层', Factory_API: '目标没有可删除或启用的厂商记录', Factory_select: '目标没有可按 Producername 搜索选择的厂商数据',
  1590. Trademark: '目标 Schema 缺少旧 Trademark 品牌主表与历史品牌数据', TrademarkAdd: '目标无可编辑或新增的 Trademark 品牌记录', Trademark_Submit: '目标缺少品牌名称、厂商、分类、启用/置顶/推荐、图片和介绍持久层', Trademark_API: '目标没有可删除或启用的品牌记录', Trademark_select: '目标没有可按 Trname 搜索选择的品牌数据'
  1591. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧商城配送动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1592. }
  1593. if (operation === 'legacyChatBlocker') {
  1594. const action = String(input.action || ''); const blockers = {
  1595. Default: '目标 Schema 缺少 ChatMsg 消息表,无法还原用户消息数列表',
  1596. UserChatHistory: '目标缺少 ChatMsg、ChatGroup 与个人/群组会话关系,无法聚合聊天对象',
  1597. UserFriend: '目标缺少旧 User_Friend 好友关系与历史数据;UserFriendGroup 只是好友分组,不是好友关系',
  1598. Group: '目标 Schema 缺少 ChatGroup 群组主表与历史群组',
  1599. GroupAdd: '目标无可定位和编辑的 ChatGroup 群组记录',
  1600. GroupAdd_Submit: '目标缺少 ChatGroup/ChatGroupUser 群主、管理员与成员关系,无法保持旧更换群主逻辑',
  1601. GroupUser: '目标 Schema 缺少 ChatGroupUser 群成员和管理员记录',
  1602. MessageHistory: '目标缺少 ChatMsg 个人/群组消息、发送者与接收者关系',
  1603. Config: '目标没有旧 RoomConfig TRTC AppID/SecretKey 配置持久层和聊天运行时',
  1604. Config_Submit: '目标没有消费 TRTC 密钥的聊天服务,保存配置不会生效且会伪造迁移完成',
  1605. Chat_API: '目标缺少 ChatMsg/ChatGroup/ChatGroupUser 可删除、启停或授权的旧聊天记录'
  1606. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧聊天动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1607. }
  1608. if (operation === 'legacyDesignClassBlocker') {
  1609. const action = String(input.action || ''); const blockers = {
  1610. ClassList: '目标 Schema 缺少 DesignTlpClass/Design_TlpClass 模板分类主表与历史分类数据',
  1611. ClassAdd: '目标无可定位或新建的模板分类数据类;DesignTlp 仅保留数字 classId 引用',
  1612. ClassAdd_Submit: '历史 DesignTlp 引用 classId=6,但分类 6 的名称、父级、排序和说明已丢失,不能伪造保存',
  1613. Class_API: '目标无可删除的模板分类记录,且直接处理 classId=6 会使既有 DesignTlp 引用悬空'
  1614. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧模板分类动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1615. }
  1616. if (operation === 'legacyComBlocker') {
  1617. const action = String(input.action || ''); const blockers = {
  1618. SPwdCheck: '目标管理员会话没有旧 Manager.RandNumber 二级密码验证状态和业务消费者,不能伪造 SPWD Session',
  1619. Import: '目标与云函数运行时缺少旧 /Config/Import/{mode}.xml 导入定义',
  1620. Import_DownTlp: '缺少旧 XML 字段别名、隐藏字段、类型与默认值规则,无法等价生成 xlsx 模板',
  1621. Import_Submit: '云函数没有旧 XML+NPOI 动态表导入流水线,也不允许按客户端表名直接插入',
  1622. ImportForContent: '目标缺少旧动态模型附表结构与通用 xlsx 内容导入页',
  1623. ImportForContent_DownTlp: '目标无法从旧模型动态附表还原完整 xlsx 字段模板',
  1624. ImportForContent_Submit: '目标缺少动态模型附表批量写入流程,商品分支还缺少 ZL_Commodities 主表'
  1625. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧通用后台动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1626. }
  1627. if (operation === 'legacyCommonBlocker') {
  1628. const action = String(input.action || ''); const blockers = {
  1629. UserBaseField: '目标缺少旧 B_UserBaseField 动态字段定义与 ZL_UserBase 原始扩展行;Profile 不是等价的动态字段模型',
  1630. ContentField: '目标只保留 CommonModel 主记录与部分规范化副表,缺少按 TableName/ModelID 动态解析任意旧内容附表的数据与渲染层',
  1631. SelUploadFiles: '旧交付包与官方源码均缺少 SelUploadFiles 视图,且当前 PostgreSQL Parse 文件适配器无法完成上传/选择流程'
  1632. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧公共动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1633. }
  1634. if (operation === 'legacySiteConfigBlocker') {
  1635. const action = String(input.action || ''); const blockers = {
  1636. Index: '旧配置首页只跳转 SiteInfo,目标没有签约的统一站点配置模型与运行时',
  1637. SiteInfo: '目标缺少 SiteConfig.SiteInfo/SiteOption 站名、Logo、站长、版权、全站 JS 与后台目录配置模型',
  1638. SiteInfo_Submit: '云函数无法更名 ASP.NET 后台路径或通过 web.config 重启宿主,也没有统一站点配置消费者',
  1639. SiteOption: '目标没有旧 SiteOption/ShopConfig/UserConfig 的域名、模板、上传、审核、积分与安全策略聚合模型',
  1640. SiteOption_Submit: '现有云函数未消费旧 SiteOption 的数十项站点/商城/互动规则,不能伪造保存生效',
  1641. MailConfig: '目标缺少 SiteConfig.MailConfig SMTP 服务器、端口、帐号和收件列表配置模型',
  1642. MailConfig_Submit: '目标无统一 SMTP 邮件发送运行时消费旧密码配置,MailTemp 只是模板而非邮件通道',
  1643. UserConfig: '目标缺少 SiteConfig.UserConfig 注册、审核、验证码、必填字段、奖励与提现规则模型',
  1644. UserConfig_Submit: '当前注册/登录云函数未实现旧 UserConfig 规则消费层,仅存储配置不会生效',
  1645. SetOrderStatus: '目标缺少 OrderConfig 订单/配送/支付状态文案配置,且 ZL_Commodities/订单主链未迁入',
  1646. SetOrderStatus_Submit: '目标无订单运行时消费 21 类旧状态文案,不能伪造保存',
  1647. SMSCfg: '目标缺少 SMSConfig 多供应商帐号、密钥、签名、模板与频率限制模型',
  1648. SMSConfig_Submit: '当前云函数没有可消费旧 SMSConfig 的短信网关,密码重置也因缺短信验证明确阻塞',
  1649. ThumbConfig: '目标缺少 WaterModuleConfig/ThumbsConfig 水印、文字样式、尺寸与缩略图规则模型',
  1650. ThumbConfig_Submit: '当前 PostgreSQL Parse 文件适配器配置错误,也没有图片上传后执行旧水印/缩略图规则的处理器',
  1651. AppConfig: '目标云函数宿主没有 ASP.NET defaultDocument、customErrors、MIME 和扩展名配置界面',
  1652. AppConfig_Submit: '云函数无法改写/重启旧 web.config 宿主,目标部署运行时也不消费这些 ASP.NET 选项',
  1653. APPConfig_ReInstall: '目标没有旧 IsInstalled 状态与 /Install/Index 重新安装流程,云函数不应破坏现有生产库'
  1654. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧站点配置动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1655. }
  1656. if (operation === 'baikeEntries') {
  1657. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); const status = Number(input.status == null ? -100 : input.status); const elite = Number(input.elite == null ? -100 : input.elite); if (search.length > 100 || ![-100,0,1].includes(status) || ![-100,0,1].includes(elite)) fail(400, '百科搜索或状态筛选无效'); const fields = await schemaFor('Baike'); const query = new Parse.Query('Baike'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => (status === -100 || Number(entry.get('status')) === status) && (elite === -100 || Number(entry.get('elite')) === elite) && (!search || [entry.get('tittle'),entry.get('brief'),entry.get('contents'),entry.get('userName'),entry.get('classification')].some((value) => String(value || '').toLowerCase().includes(search)))); results.sort((left,right) => Number(right.get('id') || 0) - Number(left.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'Baike', page, pageSize, total, results: results.map(serializeObject) } });
  1658. }
  1659. if (operation === 'baikeBatch') {
  1660. const action = String(input.action || ''); if (!['audit','unaudit','elite','unelite'].includes(action)) fail(400, '不支持的百科批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个百科词条'); const fields = await schemaFor('Baike'); const query = new Parse.Query('Baike'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分百科词条不存在或不属于当前帐套'); for (const target of targets) { if (action === 'audit') target.set('status', 1); if (action === 'unaudit') target.set('status', 0); if (action === 'elite') target.set('elite', 1); if (action === 'unelite') target.set('elite', 0); } await Parse.Object.saveAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'baike-' + action, 'Baike', target.id); return response.json({ success: true, data: { action, updated: targets.length, results: targets.map(serializeObject) } });
  1661. }
  1662. if (operation === 'legacyBaikeBlocker') {
  1663. const action = String(input.action || ''); const blockers = {
  1664. BKVersionList: '目标 Schema 缺少旧 BaikeEdit 版本表、待审核版本内容与历史版本数据',
  1665. Version_API: '目标无 BaikeEdit 版本记录,无法审核后 Apply、取消审核、驳回或删除版本',
  1666. BKList: '旧 BKList 按 Flow 读取 BaikeEdit 词条版本链,目标仅保留一条 Baike 主记录',
  1667. Config: '目标没有 GuestConfig.BKOption 百科运行时、用户组权限和积分奖励消费者',
  1668. Config_Submit: '目标没有百科创建/编辑/推荐运行时执行 CreatePoint/EditPoint/RemmPoint 规则,不能伪造配置生效'
  1669. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧百科动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1670. }
  1671. if (operation === 'saveExamClass') {
  1672. const objectId = String(input.objectId || '');
  1673. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1674. const className = String(payload.cClassName || '').trim();
  1675. const parentId = Number(input.parentId == null ? payload.cClassid || 0 : input.parentId);
  1676. const classType = Number(input.classType == null ? payload.cClassType || 1 : input.classType);
  1677. const orderId = Number(input.orderId == null ? payload.cOrderBy || 0 : input.orderId);
  1678. if (!className || className.length > 255) fail(400, '试题分类名称长度应为 1 至 255 位');
  1679. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父试题分类编号无效');
  1680. if (![1,2].includes(classType)) fail(400, '试题分类类型只能是答题类型或视频操作类型');
  1681. if (!Number.isInteger(orderId) || orderId < 0) fail(400, '试题分类排序必须是非负整数');
  1682. let companyId = pointerId(context.company);
  1683. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  1684. if (!companyId) fail(400, '试题分类必须指定帐套');
  1685. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  1686. const fields = await schemaFor('ExamClass');
  1687. let target;
  1688. let currentId = 0;
  1689. if (objectId) {
  1690. const query = new Parse.Query('ExamClass'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  1691. companyId = pointerId(target.get('company')) || companyId; currentId = Number(target.get('cId')) || 0;
  1692. } else target = new Parse.Object('ExamClass');
  1693. if (parentId > 0) {
  1694. const parent = await Psql.oneOrNone('SELECT "cId" FROM "ExamClass" WHERE "company"=$1 AND "cId"=$2 LIMIT 1', [companyId, parentId]);
  1695. if (!parent) fail(404, '父试题分类不存在或不属于当前帐套');
  1696. if (currentId) {
  1697. const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "cId","cClassid",ARRAY["cId"::text] AS path FROM "ExamClass" WHERE "company"=$1 AND "cId"=$2 UNION ALL SELECT c."cId",c."cClassid",chain.path||c."cId"::text FROM "ExamClass" c JOIN chain ON c."cId"=chain."cClassid" WHERE c."company"=$1 AND NOT c."cId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "cId"=$3 LIMIT 1', [companyId, parentId, currentId]);
  1698. if (cycle) fail(409, '不能把试题分类移动到自身或其下级分类');
  1699. }
  1700. }
  1701. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "ExamClass" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("cClassName",\'\')))=LOWER($3) LIMIT 1', [companyId, objectId, className]);
  1702. if (duplicate) fail(409, '同一帐套的试题分类名称不能重复');
  1703. target.set('cClassName', className); target.set('cClassid', parentId); target.set('cClassType', classType); target.set('company', company);
  1704. if (objectId) target.set('cOrderBy', orderId); else target.set('sourceKey', 'cloud:admin-exam-class:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10));
  1705. try { await target.save(null, { useMasterKey: true }); }
  1706. catch (error) { fail(422, '试题分类初始写入失败: ' + String(error && error.message || error)); }
  1707. try {
  1708. if (!objectId) {
  1709. const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-exam-class-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("cId"),0)+1 AS id FROM "ExamClass",lock_row WHERE "company"=$1 AND COALESCE("cId",0)>0), next_order AS MATERIALIZED (SELECT COALESCE(MAX("cOrderBy"),0)+1 AS id FROM "ExamClass",lock_row WHERE "company"=$1 AND "objectId"<>$2) UPDATE "ExamClass" SET "cId"=next_id.id,"cOrderBy"=CASE WHEN $3>0 THEN $3 ELSE next_order.id END,"sourceKey"=\'[["C_id",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id, orderId]);
  1710. currentId = Number(rows[0] && rows[0].id) || 0;
  1711. if (!currentId) throw new Error('无法分配试题分类编号');
  1712. }
  1713. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '试题分类结构写入失败: ' + String(error && error.message || error)); }
  1714. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-exam-class' : 'create-exam-class', 'ExamClass', target.id);
  1715. return response.json({ success: true, data: serializeObject(target) });
  1716. }
  1717. if (operation === 'examClassChildren') {
  1718. const parentId = Number(input.parentId || 0);
  1719. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父试题分类编号无效');
  1720. const fields = await schemaFor('ExamClass'); const query = new Parse.Query('ExamClass'); applyTenant(query, fields, context, input.companyId); query.equalTo('cClassid', parentId); query.ascending('cOrderBy'); query.limit(1000);
  1721. const results = await query.find({ useMasterKey: true });
  1722. return response.json({ success: true, data: { parentId, results: results.map(serializeObject) } });
  1723. }
  1724. if (operation === 'saveExamPoint') {
  1725. const objectId = String(input.objectId || '');
  1726. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1727. const pointName = String(payload.testPoint || '').trim();
  1728. const parentId = Number(input.parentId == null ? payload.tid || 0 : input.parentId);
  1729. const orderId = Number(input.orderId == null ? payload.orderBy || 0 : input.orderId);
  1730. if (!pointName || pointName.length > 50) fail(400, '考点名称长度应为 1 至 50 位');
  1731. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '上级考点编号无效');
  1732. if (!Number.isInteger(orderId) || orderId < 0) fail(400, '考点排序必须是非负整数');
  1733. let companyId = pointerId(context.company);
  1734. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  1735. if (!companyId) fail(400, '考点必须指定帐套');
  1736. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  1737. const fields = await schemaFor('ExamPoint');
  1738. let target;
  1739. let currentId = 0;
  1740. if (objectId) {
  1741. const query = new Parse.Query('ExamPoint'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  1742. companyId = pointerId(target.get('company')) || companyId; currentId = Number(target.get('id')) || 0;
  1743. } else target = new Parse.Object('ExamPoint');
  1744. if (parentId > 0) {
  1745. const parent = await Psql.oneOrNone('SELECT "id" FROM "ExamPoint" WHERE "company"=$1 AND "id"=$2 LIMIT 1', [companyId, parentId]);
  1746. if (!parent) fail(404, '上级考点不存在或不属于当前帐套');
  1747. if (currentId) {
  1748. const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "id","tid",ARRAY["id"::text] AS path FROM "ExamPoint" WHERE "company"=$1 AND "id"=$2 UNION ALL SELECT p."id",p."tid",chain.path||p."id"::text FROM "ExamPoint" p JOIN chain ON p."id"=chain."tid" WHERE p."company"=$1 AND NOT p."id"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "id"=$3 LIMIT 1', [companyId, parentId, currentId]);
  1749. if (cycle) fail(409, '不能把考点移动到自身或其下级考点');
  1750. }
  1751. }
  1752. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "ExamPoint" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("testPoint",\'\')))=LOWER($3) LIMIT 1', [companyId, objectId, pointName]);
  1753. if (duplicate) fail(409, '同一帐套的考点名称不能重复');
  1754. target.set('testPoint', pointName); target.set('tid', parentId); target.set('company', company);
  1755. if (objectId) target.set('orderBy', orderId);
  1756. else { target.set('sourceKey', 'cloud:admin-exam-point:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('addUser', Number(context.current.get('legacyUserId')) || 0); target.set('addTime', new Date()); }
  1757. try { await target.save(null, { useMasterKey: true }); }
  1758. catch (error) { fail(422, '考点初始写入失败: ' + String(error && error.message || error)); }
  1759. try {
  1760. if (!objectId) {
  1761. const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-exam-point-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "ExamPoint",lock_row WHERE "company"=$1 AND COALESCE("id",0)>0), next_order AS MATERIALIZED (SELECT COALESCE(MAX("orderBy"),0)+1 AS id FROM "ExamPoint",lock_row WHERE "company"=$1 AND "objectId"<>$2) UPDATE "ExamPoint" SET "id"=next_id.id,"orderBy"=CASE WHEN $3>0 THEN $3 ELSE next_order.id END,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id, orderId]);
  1762. currentId = Number(rows[0] && rows[0].id) || 0;
  1763. if (!currentId) throw new Error('无法分配考点编号');
  1764. }
  1765. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '考点结构写入失败: ' + String(error && error.message || error)); }
  1766. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-exam-point' : 'create-exam-point', 'ExamPoint', target.id);
  1767. return response.json({ success: true, data: serializeObject(target) });
  1768. }
  1769. if (operation === 'saveKnowledge') {
  1770. const objectId = String(input.objectId || '');
  1771. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1772. const knowledgeName = String(payload.kName || '').trim();
  1773. const classId = Number(input.classId == null ? payload.kClassId || 0 : input.classId);
  1774. const parentId = Number(input.parentId == null ? payload.pid || 0 : input.parentId);
  1775. const orderId = Number(input.orderId == null ? payload.kOrderBy || 0 : input.orderId);
  1776. const status = Number(payload.status == null ? 1 : payload.status);
  1777. const grade = Number(payload.grade || 0);
  1778. const isSys = Number(payload.isSys || 0);
  1779. if (!knowledgeName || knowledgeName.length > 200) fail(400, '知识点名称长度应为 1 至 200 位');
  1780. if (!Number.isInteger(classId) || classId < 1) fail(400, '请选择有效试题分类');
  1781. if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父知识点编号无效');
  1782. if (!Number.isInteger(orderId) || orderId < 0) fail(400, '知识点排序必须是非负整数');
  1783. if (![0,1].includes(status) || !Number.isInteger(grade) || grade < 0 || ![0,1].includes(isSys)) fail(400, '知识点状态、年级或系统标记无效');
  1784. let companyId = pointerId(context.company);
  1785. if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId);
  1786. if (!companyId) fail(400, '知识点必须指定帐套');
  1787. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  1788. const subject = await Psql.oneOrNone('SELECT "cId" FROM "ExamClass" WHERE "company"=$1 AND "cId"=$2 LIMIT 1', [companyId, classId]);
  1789. if (!subject) fail(404, '试题分类不存在或不属于当前帐套');
  1790. const fields = await schemaFor('QuestionsKnowledge');
  1791. let target;
  1792. let currentId = 0;
  1793. if (objectId) {
  1794. const query = new Parse.Query('QuestionsKnowledge'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
  1795. companyId = pointerId(target.get('company')) || companyId; currentId = Number(target.get('kId')) || 0;
  1796. } else target = new Parse.Object('QuestionsKnowledge');
  1797. if (parentId > 0) {
  1798. const parent = await Psql.oneOrNone('SELECT "kId" FROM "QuestionsKnowledge" WHERE "company"=$1 AND "kId"=$2 AND "kClassId"=$3 LIMIT 1', [companyId, parentId, classId]);
  1799. if (!parent) fail(404, '父知识点不存在、科目不一致或不属于当前帐套');
  1800. if (currentId) {
  1801. const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "kId","pid",ARRAY["kId"::text] AS path FROM "QuestionsKnowledge" WHERE "company"=$1 AND "kId"=$2 UNION ALL SELECT k."kId",k."pid",chain.path||k."kId"::text FROM "QuestionsKnowledge" k JOIN chain ON k."kId"=chain."pid" WHERE k."company"=$1 AND NOT k."kId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "kId"=$3 LIMIT 1', [companyId, parentId, currentId]);
  1802. if (cycle) fail(409, '不能把知识点移动到自身或其下级知识点');
  1803. }
  1804. }
  1805. const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "QuestionsKnowledge" WHERE "company"=$1 AND "objectId"<>$2 AND "kClassId"=$3 AND LOWER(TRIM(COALESCE("kName",\'\')))=LOWER($4) LIMIT 1', [companyId, objectId, classId, knowledgeName]);
  1806. if (duplicate) fail(409, '同一试题分类中的知识点名称不能重复');
  1807. target.set('kName', knowledgeName); target.set('kClassId', classId); target.set('pid', parentId); target.set('status', status); target.set('grade', grade); target.set('isSys', isSys); target.set('company', company);
  1808. if (objectId) target.set('kOrderBy', orderId);
  1809. else { target.set('sourceKey', 'cloud:admin-knowledge:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('cuser', Number(context.current.get('legacyUserId')) || 0); target.set('cdate', new Date()); }
  1810. try { await target.save(null, { useMasterKey: true }); }
  1811. catch (error) { fail(422, '知识点初始写入失败: ' + String(error && error.message || error)); }
  1812. try {
  1813. if (!objectId) {
  1814. const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-knowledge-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("kId"),0)+1 AS id FROM "QuestionsKnowledge",lock_row WHERE "company"=$1 AND COALESCE("kId",0)>0), next_order AS MATERIALIZED (SELECT COALESCE(MAX("kOrderBy"),0)+1 AS id FROM "QuestionsKnowledge",lock_row WHERE "company"=$1 AND "kClassId"=$3 AND "pid"=$4 AND "objectId"<>$2) UPDATE "QuestionsKnowledge" SET "kId"=next_id.id,"kOrderBy"=CASE WHEN $5>0 THEN $5 ELSE next_order.id END,"sourceKey"=\'[["k_id",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id, classId, parentId, orderId]);
  1815. currentId = Number(rows[0] && rows[0].id) || 0;
  1816. if (!currentId) throw new Error('无法分配知识点编号');
  1817. }
  1818. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '知识点结构写入失败: ' + String(error && error.message || error)); }
  1819. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-knowledge' : 'create-knowledge', 'QuestionsKnowledge', target.id);
  1820. return response.json({ success: true, data: serializeObject(target) });
  1821. }
  1822. if (operation === 'knowledgeChildren') {
  1823. const parentId = Number(input.parentId || 0); const classId = Number(input.classId || 0);
  1824. if (!Number.isInteger(parentId) || parentId < 0 || !Number.isInteger(classId) || classId < 1) fail(400, '知识点父级或试题分类编号无效');
  1825. const fields = await schemaFor('QuestionsKnowledge'); const query = new Parse.Query('QuestionsKnowledge'); applyTenant(query, fields, context, input.companyId); query.equalTo('kClassId', classId); query.equalTo('pid', parentId); query.ascending('kOrderBy'); query.limit(1000);
  1826. const results = await query.find({ useMasterKey: true });
  1827. return response.json({ success: true, data: { classId, parentId, results: results.map(serializeObject) } });
  1828. }
  1829. if (operation === 'saveExamTeacher') {
  1830. const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  1831. const teacherName = String(payload.tname || '').trim(); const classId = Number(input.classId == null ? payload.tclsss || 0 : input.classId);
  1832. if (!teacherName || teacherName.length > 50) fail(400, '教师姓名长度应为 1 至 50 位');
  1833. if (!Number.isInteger(classId) || classId < 0) fail(400, '任教试题分类编号无效');
  1834. let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '教师必须指定帐套');
  1835. const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true });
  1836. if (classId > 0) { const subject = await Psql.oneOrNone('SELECT "cId" FROM "ExamClass" WHERE "company"=$1 AND "cId"=$2 LIMIT 1', [companyId, classId]); if (!subject) fail(404, '任教试题分类不存在或不属于当前帐套'); }
  1837. const fields = await schemaFor('ExTeacher'); let target;
  1838. if (objectId) { const query = new Parse.Query('ExTeacher'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; }
  1839. else target = new Parse.Object('ExTeacher');
  1840. const limits = { post: 50, teach: 50, fileUpload: 255, remark: 1000 };
  1841. for (const [name, limit] of Object.entries(limits)) { const value = String(payload[name] || '').trim(); if (value.length > limit) fail(400, name + ' 超出最大长度 ' + limit); target.set(name, value); }
  1842. target.set('tname', teacherName); target.set('tclsss', classId); target.set('company', company); target.set('addUser', Number(context.current.get('legacyUserId')) || 0);
  1843. if (!objectId) { target.set('sourceKey', 'cloud:admin-exam-teacher:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('creatTime', new Date()); }
  1844. try { await target.save(null, { useMasterKey: true }); }
  1845. catch (error) { fail(422, '教师初始写入失败: ' + String(error && error.message || error)); }
  1846. try {
  1847. if (!objectId) {
  1848. const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-exam-teacher-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "ExTeacher",lock_row WHERE "company"=$1 AND COALESCE("id",0)>0) UPDATE "ExTeacher" SET "id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]);
  1849. if (!(Number(rows[0] && rows[0].id) || 0)) throw new Error('无法分配教师编号');
  1850. }
  1851. } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '教师编号写入失败: ' + String(error && error.message || error)); }
  1852. await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-exam-teacher' : 'create-exam-teacher', 'ExTeacher', target.id);
  1853. return response.json({ success: true, data: serializeObject(target) });
  1854. }
  1855. if (operation === 'barBatch') {
  1856. const action = String(input.action || '');
  1857. const actions = ['audit','unaudit','recycle','recover','move','top-global','top-section','top-undo','elite','elite-undo','bottom','bottom-undo'];
  1858. if (!actions.includes(action)) fail(400, '不支持的贴吧帖子批量操作');
  1859. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  1860. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  1861. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条帖子');
  1862. const fields = await schemaFor('GuestBar');
  1863. const query = new Parse.Query('GuestBar'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  1864. const targets = await query.find({ useMasterKey: true });
  1865. if (targets.length !== objectIds.length) fail(404, '部分帖子不存在或不属于当前帐套');
  1866. let cateId = null;
  1867. if (action === 'move') {
  1868. cateId = Number(input.cateId);
  1869. if (!Number.isInteger(cateId) || cateId < 1) fail(400, '目标贴吧分类编号无效');
  1870. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  1871. if (companyIds.length !== 1) fail(400, '批量移动的帖子必须属于同一帐套');
  1872. const category = await Psql.oneOrNone('SELECT "objectId" FROM "Guestcate" WHERE "company"=$1 AND "cateid"=$2 AND COALESCE("gtype",0)=1 LIMIT 1', [companyIds[0], cateId]);
  1873. if (!category) fail(404, '目标贴吧分类不存在或不属于当前帐套');
  1874. }
  1875. for (const target of targets) {
  1876. if (action === 'audit' || action === 'recover') target.set('status', 99);
  1877. else if (action === 'unaudit') target.set('status', 0);
  1878. else if (action === 'recycle') target.set('status', -2);
  1879. else if (action === 'move') target.set('cateId', cateId);
  1880. else if (action === 'top-global') target.set('orderFlag', 2);
  1881. else if (action === 'top-section') target.set('orderFlag', 1);
  1882. else if (action === 'bottom') target.set('orderFlag', -1);
  1883. else if (action === 'top-undo' || action === 'bottom-undo') target.set('orderFlag', 0);
  1884. else {
  1885. const flags = String(target.get('postFlag') || '').split(',').map((value) => value.trim()).filter(Boolean);
  1886. const next = new Set(flags);
  1887. if (action === 'elite') next.add('Recommend'); else next.delete('Recommend');
  1888. target.set('postFlag', next.size ? ',' + [...next].join(',') + ',' : '');
  1889. }
  1890. }
  1891. await Parse.Object.saveAll(targets, { useMasterKey: true });
  1892. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'bar-' + action, 'GuestBar', target.id);
  1893. return response.json({ success: true, data: { action, updated: targets.length, cateId, results: targets.map(serializeObject) } });
  1894. }
  1895. if (operation === 'legacyContentUtility') {
  1896. const action = String(input.action || '');
  1897. if (action === 'get-md-file') return response.json({ success: true, data: { content: '' } });
  1898. if (action === 'duplicate-title') {
  1899. const title = String(input.title || '').trim();
  1900. if (!title || title.length > 500) fail(400, '标题长度应为 1 至 500 位');
  1901. const fields = await schemaFor('CommonModel'); const query = new Parse.Query('CommonModel'); applyTenant(query, fields, context, input.companyId); query.matches('title', '^' + escapeRegex(title) + '$', 'i'); query.descending('updatedAt'); query.limit(100);
  1902. const results = await query.find({ useMasterKey: true });
  1903. return response.json({ success: true, data: { title, total: results.length, results: results.map((target) => { const item = serializeObject(target); return { objectId: item.objectId, generalId: item.generalId, title: item.title, nodeId: item.nodeId, status: item.status, updatedAt: item.updatedAt }; }) } });
  1904. }
  1905. if (action === 'export') {
  1906. const requestedIds = Array.isArray(input.objectIds) ? [...new Set(input.objectIds.map((value) => String(value || '').trim()).filter(Boolean))] : [];
  1907. if (requestedIds.length > 1000) fail(400, '每次最多导出 1000 条内容');
  1908. const fields = await schemaFor('CommonModel'); const query = new Parse.Query('CommonModel'); applyTenant(query, fields, context, input.companyId); if (requestedIds.length) query.containedIn('objectId', requestedIds); query.descending('updatedAt'); query.limit(requestedIds.length || 1000);
  1909. const results = await query.find({ useMasterKey: true });
  1910. if (requestedIds.length && results.length !== requestedIds.length) fail(404, '部分内容不存在或不属于当前帐套');
  1911. const rows = results.map((target) => ({ id: Number(target.get('generalId')) || 0, title: String(target.get('title') || ''), inputer: String(target.get('inputer') || ''), hits: Number(target.get('hits')) || 0, elite: Number(target.get('eliteLevel')) || 0, status: Number(target.get('status')) || 0, createTime: safeValue(target.get('createTime')), updateTime: safeValue(target.get('upDateTime')) }));
  1912. return response.json({ success: true, data: { total: rows.length, rows } });
  1913. }
  1914. fail(400, '不支持的旧内容兼容动作');
  1915. }
  1916. if (operation === 'legacyContentBlocker') {
  1917. const action = String(input.action || ''); const blockers = {
  1918. Content_AddToNew: '目标缺少按 CommonModel.tableName 动态复制任意旧模型附表的完整结构与数据',
  1919. AddToSpec: 'CommonModel.specialId 仅存在于 Parse Schema,PostgreSQL 物理查询仍失败,无法安全读取专题关系',
  1920. AddToSpec_Submit: '专题关系物理列不可可靠读写,且商品主表未迁移,不能伪造内容加入专题成功',
  1921. ContentManage_Html: '云函数运行时没有旧模板解析器、站点根目录与静态 HTML 文件宿主',
  1922. ContentRelease: '目标 Schema 缺少 Content_ScheTask 定时内容任务及既有任务记录',
  1923. ContentRelease_Submit: '目标没有 HFHelper 调度器与 ZL_Task_Content 脚本执行运行时',
  1924. CreateHtmlContent: '云函数没有旧节点模板、标签解析器与站点文件系统,无法预览静态生成范围',
  1925. Create_Submit: '目标没有 ContentHtmlHelper 和旧模板标签运行时,无法执行内容静态生成任务',
  1926. CreateHtml: '目标云函数不能写入旧站点 HTML 目录,也没有旧模板渲染环境',
  1927. ConCatch: '目标缺少旧采集项目、采集规则、来源站点与任务历史数据',
  1928. CatchConfig: '目标没有 SiteOption.Coll_Allow/Coll_Key 配置模型及采集运行时消费者',
  1929. CatchConfig_Submit: '仅保存采集开关不会产生可执行采集能力,目标也没有旧 SiteConfig 更新链',
  1930. MarkDown: '目标 Schema 缺少旧 Content_MarkDown 主表与历史 Markdown 记录',
  1931. MarkDown_Del: '目标没有可定位并删除的旧 Content_MarkDown 记录'
  1932. }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧内容动作'); fail(501, 'migration_blocked: ' + blockers[action]);
  1933. }
  1934. if (operation === 'contentBatch') {
  1935. const action = String(input.action || '');
  1936. if (!['status','recycle','recover','move','refresh'].includes(action)) fail(400, '不支持的内容批量操作');
  1937. const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId];
  1938. const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))];
  1939. if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条内容');
  1940. const contentFields = await schemaFor('CommonModel');
  1941. const query = new Parse.Query('CommonModel'); applyTenant(query, contentFields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length);
  1942. const targets = await query.find({ useMasterKey: true });
  1943. if (targets.length !== objectIds.length) fail(404, '部分内容不存在或不属于当前帐套');
  1944. let status = null;
  1945. let nodeId = null;
  1946. if (action === 'status') {
  1947. status = Number(input.status);
  1948. if (![-3,-1,0,99].includes(status)) fail(400, '不支持的内容状态');
  1949. } else if (action === 'recycle') status = -2;
  1950. else if (action === 'recover') status = 0;
  1951. else if (action === 'move') {
  1952. nodeId = Number(input.nodeId);
  1953. if (!Number.isInteger(nodeId) || nodeId < 1) fail(400, '请选择有效目标节点');
  1954. const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
  1955. if (companyIds.length !== 1) fail(400, '批量移动的内容必须属于同一帐套');
  1956. 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]);
  1957. if (!node) fail(404, '目标节点不存在或不属于当前帐套');
  1958. }
  1959. for (const target of targets) {
  1960. if (status !== null) target.set('status', status);
  1961. if (nodeId !== null) target.set('nodeId', nodeId);
  1962. if (action === 'refresh') { const now = new Date(); target.set('createTime', now); target.set('upDateTime', now); }
  1963. }
  1964. await Parse.Object.saveAll(targets, { useMasterKey: true });
  1965. for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'content-' + action, 'CommonModel', target.id);
  1966. return response.json({ success: true, data: { action, updated: targets.length, status, nodeId, results: targets.map(serializeObject) } });
  1967. }
  1968. const className = String(input.className || '');
  1969. assertClass(className);
  1970. const fields = await schemaFor(className);
  1971. const classWritable = !READ_ONLY_CLASSES.has(className);
  1972. if (operation === 'schema') {
  1973. 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) }));
  1974. return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && !['CommonModel','Model','ModelField','Guestbook','Currency','SysHoliday','Search','AdZone','AdInfo','FontPicShape','FontPicShapeType','DesignAsk','DesignQuestion','DesignRes','Temp','StoreApplication','StoreStyle','UserLevel'].includes(className), supportsSoftDelete: Boolean(fields.isDeleted) } });
  1975. }
  1976. if (operation === 'list') {
  1977. const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
  1978. let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId);
  1979. const search = String(input.search || '').trim();
  1980. if (search) { const searchField = ['name','title','pName','pTitle','questionTitle','tName','newsName','nodeName','specName','modelName','fieldName','fieldAlias','groupName','categoryname','dicname','cateName','gradeName','cClassName','testPoint','kName','tname','username','realName','mobile','sourceKey'].find((name) => fields[name] && fields[name].type === 'String'); if (searchField) query.matches(searchField, escapeRegex(search), 'i'); }
  1981. const sort = fields[input.sort] ? String(input.sort) : fields.updatedAt ? 'updatedAt' : 'createdAt'; if (input.order === 'asc') query.ascending(sort); else query.descending(sort);
  1982. const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize);
  1983. const results = (await query.find({ useMasterKey: true })).filter(isVisible);
  1984. return response.json({ success: true, data: { className, page, pageSize, total, results: results.map(serializeObject) } });
  1985. }
  1986. const objectId = String(input.objectId || '');
  1987. if (operation === 'get') {
  1988. 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 });
  1989. return response.json({ success: true, data: serializeObject(object) });
  1990. }
  1991. if (operation === 'save') {
  1992. if (!classWritable) fail(403, '该系统类不允许通用编辑');
  1993. if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程');
  1994. if (className === 'Group') fail(400, '用户组必须走专用保存流程');
  1995. if (className === 'Node') fail(400, '栏目必须走专用保存流程');
  1996. if (className === 'Special') fail(400, '专题必须走专用保存流程');
  1997. if (className === 'Model' || className === 'ModelField') fail(400, '模型结构必须走专用元数据流程');
  1998. if (className === 'Guestbook') fail(400, '留言必须走专用编辑或回复流程');
  1999. if (className === 'Guestcate') fail(400, '留言/贴吧分类必须走专用保存流程');
  2000. if (className === 'ExamClass') fail(400, '试题分类必须走专用保存流程');
  2001. if (className === 'ExamPoint') fail(400, '考点必须走专用保存流程');
  2002. if (className === 'QuestionsKnowledge') fail(400, '知识点必须走专用保存流程');
  2003. if (className === 'ExTeacher') fail(400, '考试教师必须走专用保存流程');
  2004. if (className === 'Datadiccategory' || className === 'Datadic') fail(400, '数据字典必须走专用保存流程');
  2005. if (className === 'GradeCate' || className === 'Grade') fail(400, '多级数据字典必须走专用保存流程');
  2006. if (className === 'Currency') fail(400, '货币必须走专用保存流程');
  2007. if (className === 'SysHoliday') fail(400, '节假日必须走专用保存流程');
  2008. if (className === 'Search') fail(400, '快捷入口必须走专用保存流程');
  2009. if (className === 'AdZone' || className === 'AdInfo') fail(400, '广告位与广告内容必须走专用保存流程');
  2010. if (className === 'FontPicShape' || className === 'FontPicShapeType') fail(400, '字体图形素材与分类必须走专用保存流程');
  2011. if (className === 'DesignAsk' || className === 'DesignQuestion' || className === 'DesignAnswer') fail(400, '问卷、题目与答卷必须走专用流程');
  2012. if (className === 'ServiceSeat' || className === 'Temp') fail(400, '客服席位与欢迎语必须走专用保存流程');
  2013. if (className === 'StoreApplication' || className === 'StoreStyle') fail(400, '店铺与店铺样式必须走专用保存流程');
  2014. if (className === 'UserLevel') fail(400, '积分等级必须走专用保存流程');
  2015. if (className === 'CRMSAttr') fail(400, 'CRM 客户类型必须走专用保存流程');
  2016. if (className === 'ShopFareTlp') fail(400, '商城运费模板必须走专用保存流程');
  2017. if (className === 'MisType') fail(400, 'OA 流程类型必须走专用保存流程');
  2018. if (className === 'PageStyle') fail(400, '黄页样式必须走专用保存流程');
  2019. if (className === 'PlatComp') fail(400, '协同办公企业必须走专用保存流程');
  2020. if (className === 'DesignRes') fail(400, '设计资源必须走专用保存流程');
  2021. if (className === 'Role') fail(400, '系统角色必须走专用保存流程');
  2022. if (className === 'CommonModel' && !objectId) fail(400, '内容新增必须同时写入模型附表,不能走通用保存');
  2023. let object;
  2024. 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);
  2025. const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
  2026. 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)); }
  2027. if (fields.company && context.company) object.set('company', context.company); if (fields.isDeleted && !objectId) object.set('isDeleted', false);
  2028. await object.save(null, { useMasterKey: true }); await audit(context, objectId ? 'update' : 'create', className, object.id);
  2029. return response.json({ success: true, data: serializeObject(object) });
  2030. }
  2031. if (operation === 'delete') {
  2032. 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 });
  2033. if (className === 'Model' || className === 'ModelField') fail(501, 'migration_blocked: 删除模型或字段需要同步删除 PostgreSQL 物理表/列并验证历史数据,托管云函数中未开放此 DDL 流程');
  2034. if (className === 'AdZone' || className === 'AdInfo') fail(400, '广告位与广告内容必须走专用批量删除流程');
  2035. if (className === 'FontPicShape' || className === 'FontPicShapeType') fail(400, '字体图形素材与分类必须走专用批量删除流程');
  2036. if (className === 'DesignAsk' || className === 'DesignQuestion' || className === 'DesignAnswer') fail(400, '问卷、题目与答卷必须走专用批量流程');
  2037. if (className === 'ServiceSeat' || className === 'Temp') fail(400, '客服席位与欢迎语必须走专用批量流程');
  2038. if (className === 'StoreApplication' || className === 'StoreStyle') fail(400, '店铺与店铺样式必须走专用批量流程');
  2039. if (className === 'CRMSAttr') fail(400, 'CRM 客户类型必须走专用批量流程');
  2040. if (className === 'ShopFareTlp') fail(400, '商城运费模板必须走专用批量流程');
  2041. if (className === 'MisType') fail(400, 'OA 流程类型必须走专用批量流程');
  2042. if (className === 'PageStyle') fail(400, '黄页样式必须走专用批量流程');
  2043. if (className === 'PlatComp') fail(400, '协同办公企业必须走专用批量流程');
  2044. if (className === 'DesignRes') fail(400, '设计资源必须走专用批量流程');
  2045. if (className === 'Role') fail(400, '系统角色必须走专用批量流程');
  2046. if (className === 'Guestcate') { const cateid = Number(object.get('cateid')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "Guestcate" WHERE "company"=$1 AND "parentId"=$2) AS children,(SELECT COUNT(*)::int FROM "Guestbook" WHERE "company"=$1 AND "cateid"=$2) AS messages,(SELECT COUNT(*)::int FROM "GuestBar" WHERE "company"=$1 AND "cateId"=$2) AS posts', [companyId, cateid]); if (Number(refs.children) || Number(refs.messages) || Number(refs.posts)) fail(409, '分类仍被下级分类、留言或帖子引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-guest-category', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
  2047. if (className === 'ExamClass') { const classId = Number(object.get('cId')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "ExamClass" WHERE "company"=$1 AND "cClassid"=$2) AS children,(SELECT COUNT(*)::int FROM "ExamSysQuestions" WHERE "company"=$1 AND "pClass"=$2) AS questions', [companyId, classId]); if (Number(refs.children) || Number(refs.questions)) fail(409, '试题分类仍被下级分类或试题引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-exam-class', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
  2048. if (className === 'ExamPoint') { const pointId = Number(object.get('id')) || 0; const companyId = pointerId(object.get('company')); const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "ExamPoint" WHERE "company"=$1 AND "tid"=$2', [companyId, pointId]); if (Number(children.count)) fail(409, '考点仍有下级考点,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-exam-point', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
  2049. if (className === 'QuestionsKnowledge') { const knowledgeId = Number(object.get('kId')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "QuestionsKnowledge" WHERE "company"=$1 AND "pid"=$2) AS children,(SELECT COUNT(*)::int FROM "ExamSysQuestions" WHERE "company"=$1 AND "pKnowledge"=$2) AS questions', [companyId, knowledgeId]); if (Number(refs.children) || Number(refs.questions)) fail(409, '知识点仍被下级知识点或试题引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-knowledge', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
  2050. if (className === 'Datadiccategory' || className === 'Datadic') { if (className === 'Datadiccategory') { const categoryId = Number(object.get('diccateid')) || 0; const itemFields = await schemaFor('Datadic'); const refs = new Parse.Query('Datadic'); applyTenant(refs, itemFields, context, pointerId(object.get('company'))); refs.equalTo('diccate', categoryId); if (await refs.count({ useMasterKey: true })) fail(409, '字典分类仍有字典项,不能删除'); } await object.destroy({ useMasterKey: true }); await audit(context, 'delete-dictionary', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
  2051. if (className === 'GradeCate' || className === 'Grade') { const referenceFields = await schemaFor('Grade'); const refs = new Parse.Query('Grade'); applyTenant(refs, referenceFields, context, pointerId(object.get('company'))); if (className === 'GradeCate') refs.equalTo('cate', Number(object.get('cateId')) || 0); else refs.equalTo('parentId', Number(object.get('gradeId')) || 0); if (await refs.count({ useMasterKey: true })) fail(409, className === 'GradeCate' ? '多级字典分类仍有选项,不能删除' : '多级字典选项仍有下级选项,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-grade', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
  2052. if (className === '_User') assertCanManageUser(context, object, 'lock');
  2053. 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, '该用户组仍有下级组,不能删除'); }
  2054. 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 } }); }
  2055. 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 } }); }
  2056. 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 } }); }
  2057. 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 } }); }
  2058. if (fields.isDeleted) { object.set('isDeleted', true); await object.save(null, { useMasterKey: true }); } else await object.destroy({ useMasterKey: true });
  2059. if (className === '_User') await revokeSessions([object]);
  2060. await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } });
  2061. }
  2062. if (operation === 'resetPassword') {
  2063. if (className !== '_User' || !objectId || typeof input.newPassword !== 'string' || input.newPassword.length < 8) fail(400, '密码至少 8 位');
  2064. 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 } });
  2065. }
  2066. fail(400, '不支持的后台操作');
  2067. } catch (error) {
  2068. const status = Number(error.status || (error.code === 101 ? 404 : 500));
  2069. return response.status(status).json({ success: false, error: status >= 500 ? 'cloud_function_error' : 'request_rejected', message: error.message || '云函数执行失败' });
  2070. }
  2071. }
  2072. `;
  2073. function cmsReadCode(mode) {
  2074. return String.raw`
  2075. const MODE = ${JSON.stringify(mode)};
  2076. const HIDDEN = /(?:password|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword)/i;
  2077. function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; }
  2078. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  2079. 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; }
  2080. 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 }; }
  2081. async function schema(name) { try { return (await new Parse.Schema(name).get({ useMasterKey: true })).fields || {}; } catch (_) { return {}; } }
  2082. function tenant(query, fields, context) { if (fields.company && context.company) query.equalTo('company', context.company); }
  2083. 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(); }
  2084. function escapeRegex(value) { return String(value).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); }
  2085. async function handler(request, response) {
  2086. try {
  2087. 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));
  2088. if (MODE === 'content-normalizer') return response.json({ success: true, data: { content: stripHtml(input.content), mode: String(input.mode || 'strip') } });
  2089. if (MODE.startsWith('user-')) {
  2090. 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);
  2091. if (MODE === 'user-extended') { delete data.legacyUserPlat; delete data.legacyWxUser; delete data.wechat; delete data.wxapp; return response.json({ success: true, data }); }
  2092. if (MODE === 'user-platform') return response.json({ success: true, data: { objectId: target.id, platform: safe(target.get('legacyUserPlat') || {}), company: safe(target.get('company')) } });
  2093. return response.json({ success: true, data: { objectId: target.id, wechat: safe(target.get('legacyWxUser') || target.get('wechat') || target.get('wxapp') || {}) } });
  2094. }
  2095. if (MODE === 'exam-classes' || MODE === 'guest-bar') {
  2096. 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) } });
  2097. }
  2098. if (MODE === 'search') {
  2099. 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: [] } });
  2100. 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; }) } });
  2101. }
  2102. fail(400, '不支持的 CMS 查询');
  2103. } 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 || '云函数执行失败' }); }
  2104. }
  2105. `;
  2106. }
  2107. const appGatewayCode = String.raw`
  2108. const DEFAULT_COMPANY_ID = '7pIbDBJmKx';
  2109. const PUBLIC_READ = new Set(['content_list','content_get','content_uphis','node_list','node_get','app_update']);
  2110. const BLOCKED = {
  2111. mcode_send: '缺少新短信服务商凭据与验证码存储', user_login_mobile: '缺少新短信验证码服务', user_register_mobile: '缺少新短信验证码服务', user_sync2: '依赖微信容器授权与新版微信凭据',
  2112. cart_list: '目标 Schema 无购物车类', coupon_list: '目标 Schema 无优惠券实例类', coupon_usrgot_add: '目标 Schema 无用户优惠券类', coupon_usrgot_list: '目标 Schema 无用户优惠券类',
  2113. order_comment_add: '目标 Schema 无订单评价类', order_comment_list: '目标 Schema 无订单评价类', order_delivery: '目标 Schema 无订单与物流类', order_get: '目标 Schema 无订单类', order_list: '目标 Schema 无订单类', order_signfor: '目标 Schema 无订单类',
  2114. payment_cart: '目标 Schema 无订单与支付明细类', payment_cart_again: '目标 Schema 无订单与支付明细类', payment_success: '目标 Schema 无支付明细类',
  2115. invoice_add: '目标 Schema 无发票类', invoice_del: '目标 Schema 无发票类', invoice_get: '目标 Schema 无发票类', invoice_list: '目标 Schema 无发票类', invoice_upd: '目标 Schema 无发票类',
  2116. receaddr_add: '目标 Schema 无收货地址类', receaddr_del: '目标 Schema 无收货地址类', receaddr_get: '目标 Schema 无收货地址类', receaddr_list: '目标 Schema 无收货地址类', receaddr_upd: '目标 Schema 无收货地址类',
  2117. user_bank_add: '目标 Schema 无提现账户类', user_bank_del: '目标 Schema 无提现账户类', user_bank_get: '目标 Schema 无提现账户类', user_bank_list: '目标 Schema 无提现账户类', user_cash_add: '缺少提现事务账本', user_cash_list: '缺少提现事务账本',
  2118. user_coin_recharge: '缺少支付凭据与事务账本', user_exp_transfer: '缺少积分事务规则确认', user_money_recharge: '缺少支付凭据与事务账本', user_money_transfer: '缺少资金事务规则确认',
  2119. user_group_usr_supply: '目标 Schema 无会员续费订单类', user_group_usr_upgrade: '目标 Schema 无会员升级订单类', user_shop_order: '目标 Schema 无订单类', user_shop_sales: '目标 Schema 无销售明细类',
  2120. user_star_add: '目标 Schema 无收藏关系类', user_star_del: '目标 Schema 无收藏关系类', user_star_is: '目标 Schema 无收藏关系类', user_update_paypwd: '需要独立支付密码哈希服务',
  2121. user_update_pwd: '旧流程强制校验短信验证码,但新短信服务与验证码存储尚未提供', user_update_pwdall: '同时依赖登录密码与支付密码写入,目标系统缺少独立支付密码哈希服务',
  2122. vote_add: '目标 Schema 无可证明的投票记录类', vote_ask: '目标 Schema 无可证明的投票记录类', vote_question: '目标 Schema 无可证明的投票记录类',
  2123. product_list: '目标 Product 仅是 ZL_P_Product 附表,缺少 ZL_Commodities 商品主表', product_get: '目标 Product 仅是 ZL_P_Product 附表,缺少商品名称、价格、正文、所有者与主键关系', product_stock_list: '目标 Schema 无 ZL_Shop_Stock 库存流水类',
  2124. product_del: '缺少 ZL_Commodities 主表及其 UserID 所有权字段', product_stock_change: '缺少 ZL_Commodities 主表、当前库存与 ZL_Shop_Stock 流水类', product_sale_change: '缺少 ZL_Commodities 主表及 Sales/UserID 字段',
  2125. unit_record_update: '旧源码在调用前无条件 return;目标库也未迁移 Model 57 单元聚合副表',
  2126. e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
  2127. pub_add: '旧前端依赖 Pub 7-13,但目标库只迁入 Pub 2-6,且缺少注销、供应商、商户邀请、退换货与结算副表', pub_list: '旧前端依赖 Pub 7-13,但目标库只迁入 Pub 2-6,无法恢复对应历史记录与审核范围',
  2128. user_rnauth_add: '实名认证需新的合规审核与敏感数据存储', user_rnauth_get: '实名认证需新的合规审核与敏感数据存储', user_rnauth_upd: '实名认证需新的合规审核与敏感数据存储',
  2129. product_add: '缺少 ZL_Commodities 商品主表,无法与 Product 附表进行事务新增', product_upd: '缺少 ZL_Commodities 商品主表,无法恢复主表与附表事务更新'
  2130. };
  2131. const SENSITIVE = /(?:password|pwd|secret|sessiontoken|masterkey|privatekey|legacyPasswordHash|adminPassword|payPassword)/i;
  2132. function inputOf(request) { const body = request.body || {}; return body.params && typeof body.params === 'object' ? body.params : body; }
  2133. function envelope(result, addon, page) { const value = { retcode: 0, retmsg: '', result }; if (addon !== undefined) value.addon = addon; if (page) value.page = page; return value; }
  2134. function reject(message) { return { retcode: -1, retmsg: message, result: null }; }
  2135. function fail(status, message) { const error = new Error(message); error.status = status; throw error; }
  2136. 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; }
  2137. function number(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }
  2138. 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))) }; }
  2139. function companyPointer() { return Parse.Object.createWithoutData('Company', DEFAULT_COMPANY_ID); }
  2140. async function fieldsOf(className) { return (await new Parse.Schema(className).get({ useMasterKey: true })).fields || {}; }
  2141. function tenant(query, fields) { if (fields.company) query.equalTo('company', companyPointer()); }
  2142. 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); }
  2143. function legacyAliases(value, className) {
  2144. const row = safe(value); const maps = {
  2145. 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' },
  2146. 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' },
  2147. App: { ID:'id' }
  2148. }; 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;
  2149. }
  2150. 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; }
  2151. 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; }
  2152. function objectValue(source, key) { return source && typeof source.get === 'function' ? source.get(key) : source && source[key]; }
  2153. function legacyData(source) { const value = objectValue(source, 'legacyUserData'); return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
  2154. function firstValue(...values) { return values.find((value) => value !== undefined && value !== null && value !== ''); }
  2155. function legacyUser(current, withToken = false) {
  2156. 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')));
  2157. 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'),'') || '') };
  2158. if (withToken) value.sessionToken = current.getSessionToken(); return value;
  2159. }
  2160. function legacyUserAddon(current) {
  2161. 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;
  2162. }
  2163. async function registerUser(input) {
  2164. 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,'邀请码不存在'); }
  2165. 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; }
  2166. 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; }
  2167. }
  2168. async function updateUserProfile(input,current) {
  2169. 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];
  2170. 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; }
  2171. current.set('legacyUserData',data); await current.save(null,{useMasterKey:true}); return { result:legacyUser(current),addon:legacyUserAddon(current) };
  2172. }
  2173. function legacyUserRow(source) {
  2174. 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,'') || '') };
  2175. 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 };
  2176. }
  2177. 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; }
  2178. 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 } }; }
  2179. const CONTENT_ADDONS = {
  2180. 52: { className:'VocabularyWord', fields:['id','lj','sy','yb','yp'] },
  2181. 53: { className:'PracticeRecord', fields:['id','kcid','scid','xxcs','yhid','jrscb'] },
  2182. 54: { className:'CourseAppointment', fields:['id','pl','bxrq','dslx','dszt','fxpl','jffs','jssj','kcid','kssj','plxm','scsj','sdsd','szmd','szyh','yysj','yykcid'] },
  2183. 56: { className:'DailyStudyRecord', fields:['id','pl','con','djq','ygg','dqrq','dsid','fxrl','xxqs','szmdid','userId','learned'] },
  2184. 58: { className:'CourseBinding', fields:['id','yxx','cksl','kcid','syjd','yhid'] },
  2185. 59: { className:'LessonRecord', fields:['id','pf','jffs','jsmz','kcid','kclx','kcmc','kzsj','pjnr','pldp','xymz','yyds','plpjsj','szmdid'] },
  2186. 60: { className:'MemoryPracticeRecord', fields:['id','fxzt','kcid','plid','wcsj','yhid','kywrq','kywsj','xxjlid','orderId'] },
  2187. 61: { className:'AssessmentProfile', fields:['id','df','askid','wrong','userId','answerid','dontKnow','prevScore','totalScore'] }
  2188. };
  2189. const NODE_MODELS = { 28:58, 29:54, 32:53, 291:56, 296:59, 327:52, 388:60, 389:61 };
  2190. const PRIVATE_CONTENT_MODELS = new Set([53,54,56,58,59,60,61]);
  2191. const CONTENT_WRITE_AUTH = {
  2192. 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 },
  2193. 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 }
  2194. };
  2195. 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); }
  2196. function requestedContentModel(input) { const nodes = String(input.nodeid || input.nid || input.nodes || '').split(',').map(number).filter(Boolean); return contentModel(input, nodes); }
  2197. function legacyFilterPairs(input) {
  2198. const source = [input.myfield, input.myfield2].filter(Boolean).join('|'); if (!source) return [];
  2199. 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] }; });
  2200. }
  2201. function addonField(config, requested) { return config && config.fields.find((field) => field.toLowerCase() === String(requested).toLowerCase()); }
  2202. function contentQueryRequiresAuth(action, input) { return (action === 'content_list' || action === 'content_list_llk') && (PRIVATE_CONTENT_MODELS.has(requestedContentModel(input)) || legacyFilterPairs(input).length > 0); }
  2203. async function authorizeContentAccess(current, input) {
  2204. 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));
  2205. }
  2206. 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'); }
  2207. async function authorizeOwnerPairs(current, pairs) {
  2208. if (isAdminUser(current)) return; const owners = pairs.filter((pair) => ['userid','yhid','xymz','jsmz','szyh'].includes(pair.name)); if (!owners.length) fail(403, '私有内容查询必须限定用户');
  2209. 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;
  2210. 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, '无权查看该学员的内容记录'); }
  2211. }
  2212. 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); }
  2213. function contentOrder(input, config) {
  2214. const raw = String(input.orders || '').trim(); if (!raw) return 'c."updatedAt" DESC'; if (/^NEWID\(\)$/i.test(raw)) return 'RANDOM()';
  2215. 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();
  2216. 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';
  2217. }
  2218. async function contentPage(input, publicOnly = false) {
  2219. const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['c."company" = $1'];
  2220. if (input.objectId) { values.push(String(input.objectId)); clauses.push('c."objectId" = $' + values.length); }
  2221. if (input.generalId) { values.push(String(input.generalId)); clauses.push('CAST(c."generalId" AS text) = $' + values.length); }
  2222. const nodeIds = String(input.nodeid || input.nid || input.nodes || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0');
  2223. if (nodeIds.length) { values.push(nodeIds); clauses.push('CAST(c."nodeId" AS text) = ANY($' + values.length + '::text[])'); }
  2224. 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); }
  2225. if (publicOnly && !modelId) { values.push([...PRIVATE_CONTENT_MODELS].map(String)); clauses.push('CAST(c."modelId" AS text) <> ALL($' + values.length + '::text[])'); }
  2226. const filters = legacyFilterPairs(input); if (filters.length && !config) fail(501, '该内容模型的 addon 联表尚未映射');
  2227. 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); }
  2228. if (input.skey) { values.push('%' + String(input.skey).replace(/[%_]/g, '') + '%'); clauses.push('c."title" ILIKE $' + values.length); }
  2229. 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 ');
  2230. 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]);
  2231. 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);
  2232. 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);
  2233. return { rows: normalized, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
  2234. }
  2235. async function sqlPage(className, input, filters = []) {
  2236. if (!['App','Node'].includes(className)) fail(400, '不允许查询该类'); const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID]; const clauses = ['"company" = $1'];
  2237. for (const filter of filters) { values.push(String(filter.value)); clauses.push('CAST("' + filter.field + '" AS text) = $' + values.length); }
  2238. 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]);
  2239. 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);
  2240. 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 } };
  2241. }
  2242. async function nodePage(input) {
  2243. 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} };
  2244. }
  2245. async function nodeUnitPage(input,current) {
  2246. 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} };
  2247. }
  2248. 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; }
  2249. 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 []; } }
  2250. 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))) }; }
  2251. function ownLegacyId(current) { return number(current && (current.get('legacyUserId') || current.get('userid') || current.get('num'))); }
  2252. async function legacyUserObject(legacyId) { const fields = await fieldsOf('_User'); return findByLegacyId('_User', fields, legacyId, ['legacyUserId','userid','num']); }
  2253. async function chainContains(startId, expectedAncestorId) {
  2254. let cursor = number(startId); const expected = number(expectedAncestorId); const visited = new Set();
  2255. 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); }
  2256. return false;
  2257. }
  2258. async function authorizeTeamUser(current, requestedId) {
  2259. const ownId = ownLegacyId(current); const targetId = number(requestedId || ownId); if (!targetId) fail(400, '缺少用户 ID'); if (isAdminUser(current) || targetId === ownId) return targetId;
  2260. if (await chainContains(targetId, ownId)) return targetId; if (await chainContains(ownId, targetId)) return targetId; fail(403, '无权查看该团队成员');
  2261. }
  2262. 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"'; }
  2263. async function teamUserPage(input, current) {
  2264. const paging = pageInput(input); const parentId = number(input.puid || ownLegacyId(current)); if (!parentId && !isAdminUser(current)) fail(400, '团队列表必须指定上级用户'); if (parentId) await authorizeTeamUser(current,parentId);
  2265. 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); }
  2266. 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[])'); }
  2267. 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,' : '';
  2268. 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);
  2269. return { rows:rows.map(legacyUserRow), page:{ itemCount:total, pageCount:Math.ceil(total / paging.size), pageIndex:paging.index, pageSize:paging.size } };
  2270. }
  2271. async function teamStats(input,current) {
  2272. 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;
  2273. }
  2274. async function coachStudentPage(input,current) {
  2275. 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 } };
  2276. }
  2277. const POINT_HISTORY_CLASSES = { 1:'UserExpDomP',2:'UserSIcon',3:'UserExpHis',4:'UserUserPoint',5:'UserDummyPoint',6:'UserCredit' };
  2278. 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; }
  2279. async function pointHistoryPage(input,current) {
  2280. 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 } : {} };
  2281. }
  2282. async function createGuestbook(input,current) {
  2283. 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 };
  2284. }
  2285. async function authorizeRequestedUser(current, requestedId) {
  2286. const targetId = number(requestedId || ownLegacyId(current)); if (!targetId) fail(400, '缺少用户 ID'); if (isAdminUser(current) || targetId === ownLegacyId(current)) return targetId;
  2287. 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;
  2288. }
  2289. async function authorizeBusinessRow(current, row, subjectFields, actorFields = []) {
  2290. 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;
  2291. 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, '无权查看该业务记录');
  2292. }
  2293. function normalizeWordRow(source) { const addon = source.__addon || {}; const merged = { ...source, ...addon }; delete merged.__addon; return legacyAliases(merged, 'CommonModel'); }
  2294. async function orderDetailRow(inputId, current, authorize = true) {
  2295. 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]);
  2296. 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 || '课程预约' }; }
  2297. 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;
  2298. }
  2299. async function recordDetailData(input, current) {
  2300. 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]);
  2301. 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 = [];
  2302. 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]);
  2303. 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] }; });
  2304. const order = recordAddon.dsid ? await orderDetailRow(recordAddon.dsid, current, false) : null; return { words, addon: order ? [order] : [], record };
  2305. }
  2306. async function newWordPage(input, current) {
  2307. 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]));
  2308. 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 } };
  2309. }
  2310. async function courseWordPage(input, current) {
  2311. 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 } };
  2312. }
  2313. async function updateLearningWords(input, current) {
  2314. 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);
  2315. 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(','));
  2316. 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;
  2317. 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); }
  2318. 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) };
  2319. }
  2320. 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 + '格式错误'); }
  2321. function cleanText(value, max) { return String(value == null ? '' : value).trim().slice(0, max); }
  2322. 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; }
  2323. async function upsertStudyRecord(input, current) {
  2324. 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) || '学习记录';
  2325. 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;
  2326. if (found) { record = await new Parse.Query('DailyStudyRecord').get(found.recordObjectId, { useMasterKey: true }); common = await new Parse.Query('CommonModel').get(found.commonObjectId, { useMasterKey: true }); }
  2327. 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]))); } };
  2328. 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 }; }
  2329. 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 };
  2330. }
  2331. function reviewSchedule() {
  2332. 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');
  2333. 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(',');
  2334. }
  2335. async function createStudyContent(input, current) {
  2336. 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');
  2337. 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;
  2338. 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)));
  2339. 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));
  2340. 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; }
  2341. return String(generalId);
  2342. }
  2343. 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); }
  2344. function writeValue(value, definition) {
  2345. if (!definition) return value; if (definition.type === 'Number') { const parsed = Number(value); if (!Number.isFinite(parsed)) fail(400, '数值字段格式错误'); return parsed; }
  2346. 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; }
  2347. if (definition.type === 'String') return typeof value === 'string' ? value.slice(0, 50000) : JSON.stringify(value).slice(0, 50000); fail(400, '不支持写入字段类型 ' + definition.type);
  2348. }
  2349. 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; }
  2350. function restoreFields(object, snapshot) { for (const [field,state] of Object.entries(snapshot)) { if (state.exists) object.set(field, state.value); else object.unset(field); } }
  2351. async function updateContentPair(input, current) {
  2352. 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');
  2353. 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');
  2354. 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);
  2355. 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); }
  2356. 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'); }
  2357. 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'));
  2358. }
  2359. async function createContentPair(input, current) {
  2360. 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');
  2361. 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;
  2362. 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])); }
  2363. 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); }
  2364. 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; }
  2365. return String(generalId);
  2366. }
  2367. 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; }
  2368. 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; }
  2369. async function deductOrderPeriod(student, uid, type, orderId, detail) {
  2370. 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 };
  2371. }
  2372. async function rollbackOrderPeriod(studentId, config, periodKey, pointKey) {
  2373. 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]);
  2374. }
  2375. async function memoryReviewsForOrder(orderId, order, current) {
  2376. 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 = [];
  2377. 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; }
  2378. }
  2379. 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; }
  2380. async function updateOrderStatus(input, current) {
  2381. 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 };
  2382. 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); }
  2383. 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) }; }
  2384. 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; }
  2385. }
  2386. async function orderStatistics(input, current) {
  2387. 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;
  2388. 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; }
  2389. 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; }
  2390. return { t30:String(t30), t60:String(t60), t_tiyan:String(tTiyan), t_shichang:String(duration), t_total:String(total), t_yongji:String(commission) };
  2391. }
  2392. 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); }
  2393. async function memoryPage(input, current) {
  2394. 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]);
  2395. 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 } };
  2396. }
  2397. 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 }; }
  2398. async function handler(request, response) {
  2399. try {
  2400. const input = inputOf(request); const action = String(input.action || ''); if (!action) return response.status(400).json(reject('缺少 action'));
  2401. 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 }));
  2402. if (BLOCKED[action]) return response.status(501).json(reject('migration_blocked: ' + BLOCKED[action]));
  2403. if (action === 'user_login_passwd') {
  2404. const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username || !password) return response.status(400).json(reject('请输入账号和密码'));
  2405. 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('账号或密码错误')); }
  2406. }
  2407. if (action === 'user_register') { const registered = await registerUser(input); return response.json(envelope(registered.result,registered.addon)); }
  2408. 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('用户不存在')); }
  2409. 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);
  2410. 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))); }
  2411. if (action === 'user_list') { const result = await teamUserPage(input,current); return response.json(envelope(result.rows,undefined,result.page)); }
  2412. if (action === 'user_dept') return response.json(envelope(await teamStats(input,current)));
  2413. if (action === 'e_user_list') { const result = await coachStudentPage(input,current); return response.json(envelope(result.rows,undefined,result.page)); }
  2414. if (action === 'user_update') { const updated = await updateUserProfile(input,current); return response.json(envelope(updated.result,updated.addon)); }
  2415. if (action === 'app_update') { const result = await sqlPage('App', { page: 1, pageSize: 1 }); return response.json(envelope(legacyAppRow(result.rows[0]))); }
  2416. if (action === 'content_list' || action === 'content_list_llk') { const result = await contentPage(input, !current); return response.json(envelope(result.rows, undefined, result.page)); }
  2417. 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])); }
  2418. 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) })); }
  2419. if (action === 'content_add') return response.json(envelope(await createContentPair(input, current)));
  2420. if (action === 'content_update') return response.json(envelope(await updateContentPair(input, current)));
  2421. if (action === 'node_list') { const result = privateNodeProgress ? await nodeUnitPage(input,current) : await nodePage(input); return response.json(envelope(result.rows, undefined, result.page)); }
  2422. 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('栏目不存在')); }
  2423. const legacyId = ownLegacyId(current);
  2424. if (action === 'content_add_zt') return response.json(envelope(await createStudyContent(input, current)));
  2425. if (action === 'e_order_update_v2') return response.json(envelope(await updateOrderStatus(input,current)));
  2426. if (action === 'e_order_tongji') return response.json(envelope(await orderStatistics(input, current)));
  2427. if (action === 'stu_record_update_v2') return response.json(envelope(await upsertStudyRecord(input, current)));
  2428. if (action === 'e_add_words') return response.json(envelope(await updateLearningWords(input, current)));
  2429. if (action === 'e_ck_list') { const result = await courseWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
  2430. if (action === 'e_get_21list') { const result = await memoryPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
  2431. if (action === 'e_words_list') { const result = await newWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
  2432. if (action === 'e_order_detail') { const found = await orderDetailRow(input.id, current); return found ? response.json(envelope([found])) : response.status(404).json(reject('预约记录不存在')); }
  2433. 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('学习记录不存在')); }
  2434. if (action === 'user_point_list') { const result = await pointHistoryPage(input,current); return response.json(envelope(result.rows,result.addon,result.page)); }
  2435. if (action === 'guestbook_add') return response.json(envelope(await createGuestbook(input,current)));
  2436. return response.status(501).json(reject('migration_blocked: 尚未完成动作映射 ' + action));
  2437. } 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)); }
  2438. }
  2439. `;
  2440. const definitions = [
  2441. { name: 'xiaoshu.admin.gateway', desc: '小树陪练 Angular 管理后台统一数据网关(权限、帐套、CRUD、统计)', path: 'xiaoshu/admin/gateway', code: adminGatewayCode, params: [{ name: 'operation', type: 'String', required: true }] },
  2442. { 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 }] },
  2443. { name: 'cms.users.extended', desc: '替代 ZL_EX_UserView,返回安全用户扩展资料', path: 'xiaoshu/cms/users/extended', code: cmsReadCode('user-extended'), params: [{ name: 'userId', type: 'String', required: true }] },
  2444. { name: 'cms.users.platform', desc: '替代 ZL_User_PlatView', path: 'xiaoshu/cms/users/platform', code: cmsReadCode('user-platform'), params: [{ name: 'userId', type: 'String', required: true }] },
  2445. { name: 'cms.users.wechat', desc: '替代 ZL_User_WXView,过滤令牌与密钥', path: 'xiaoshu/cms/users/wechat', code: cmsReadCode('user-wechat'), params: [{ name: 'userId', type: 'String', required: true }] },
  2446. { 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 }] },
  2447. { 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 }] },
  2448. { name: 'cms.search', desc: '替代 ZL_SearchView,搜索规范化 CommonModel', path: 'xiaoshu/cms/search', code: cmsReadCode('search'), params: [{ name: 'keyword', type: 'String', required: true }] },
  2449. { name: 'xiaoshu.app.gateway', desc: '旧 WXAPP action 到 Parse 规范化类的兼容云函数;无法证明等价的动作返回 migration_blocked', path: 'xiaoshu/app/gateway', code: appGatewayCode, params: [{ name: 'action', type: 'String', required: true }] },
  2450. ];
  2451. function validateDefinition(definition) {
  2452. if (definition.path.startsWith('/')) throw new Error(`${definition.name}: 当前执行器的全局 path 不接受前导 /`);
  2453. const factory = new Function(`${definition.code}\nreturn typeof handler;`);
  2454. if (factory() !== 'function') throw new Error(`${definition.name}: 未定义 handler`);
  2455. }
  2456. async function parseRequest(path, init = {}) {
  2457. const response = await fetch(`${PARSE_URL}${path}`, {
  2458. ...init,
  2459. headers: {
  2460. 'X-Parse-Application-Id': APP_ID,
  2461. 'X-Parse-Master-Key': MASTER_KEY,
  2462. 'Content-Type': 'application/json',
  2463. ...(init.headers || {}),
  2464. },
  2465. });
  2466. const payload = await response.json();
  2467. if (!response.ok || payload.error) throw new Error(payload.error || `Parse 请求失败:${response.status}`);
  2468. return payload;
  2469. }
  2470. async function upsert(definition) {
  2471. const where = encodeURIComponent(JSON.stringify({ name: definition.name }));
  2472. const existing = await parseRequest(`/classes/Function?where=${where}&limit=1&keys=objectId`);
  2473. const body = {
  2474. name: definition.name,
  2475. desc: definition.desc,
  2476. type: 'standalone',
  2477. path: definition.path,
  2478. code: definition.code.trim(),
  2479. params: definition.params,
  2480. paramList: definition.params,
  2481. respType: 'json',
  2482. respJson: { success: true, data: {} },
  2483. enabled: true,
  2484. version: '1.1.2',
  2485. };
  2486. const objectId = existing.results?.[0]?.objectId;
  2487. if (objectId) {
  2488. await parseRequest(`/classes/Function/${objectId}`, { method: 'PUT', body: JSON.stringify(body) });
  2489. return { action: 'updated', objectId };
  2490. }
  2491. const created = await parseRequest('/classes/Function', { method: 'POST', body: JSON.stringify(body) });
  2492. return { action: 'created', objectId: created.objectId };
  2493. }
  2494. for (const definition of definitions) validateDefinition(definition);
  2495. if (validateOnly) {
  2496. console.log(`Validated ${definitions.length} cloud function definitions.`);
  2497. } else {
  2498. if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY;不会把 masterKey 写入项目文件。');
  2499. for (const definition of definitions) {
  2500. const result = await upsert(definition);
  2501. console.log(`${result.action.padEnd(7)} ${definition.path} (${result.objectId})`);
  2502. }
  2503. }